0.10.0-alpha.18 • Published 6 days ago

lenrix v0.10.0-alpha.18

Weekly downloads
3
License
MIT
Repository
github
Last release
6 days ago

lenrix

🔎 + Redux + RxJS + TypeScript = ❤️

Table of Contents

Motivation

A lot of people have complained about redux, some with good reason. Many have been drawn to other state management solutions.

Don't throw the baby with the bathwater.

Although we agree there must be a better way than classical redux, we are not willing to sacrifice all of the redux goodness you've heard so much about.

Making redux great again !

lenrix is a redux store wrapper that :

  • Dramatically reduces boilerplate
  • Eliminates the need for thunky middleware and selector libraries
  • Makes no compromise on type-safety
  • Embraces reactive programming
  • Prevents unnecessary re-rendering

Features

  • Declarative API for deep state manipulation, powered by immutable-lens
  • Reactive state and selectors, powered by rxjs
  • Relevant reference equality checks performed out of the box
  • Separate functional slices with Focused Stores
  • Epics, just like redux-observable, our favorite redux middleware

Quickstart

Install

npm install --save lenrix redux rxjs immutable-lens

rootStore.ts

import { createStore } from 'lenrix'

const initialRootState = {
   message: ''
}

export const rootStore = createStore(initialRootState)
      .actionTypes<{ // DECLARE ACTION AND PAYLOAD TYPES
         setMessage: string
      }>()
      .updates({ // REGISTER UPDATES (~= CURRIED REDUCERS)
         setMessage: (message) => (state) => ({...state, message})
      }))

storeConsumer.ts

import { rootStore } from './rootStore'

const message$ = rootStore.pluck('message') // Observable<string>
const slice$ = rootStore.pick('message') // Observable<{message: string}>

rootStore.dispatch({ setMessage: 'Hello !!!' })

API

Create

createStore()

import { createStore } from 'lenrix'

const rootStore = createStore({
   user: {
      id: 123,
      name: 'John Doe'
   }
})

createFocusableStore()

Provides the same API as createStore() from redux.

import {createFocusableStore} from 'lenrix'

const initialRootState = { ... }

export type RootState = typeof initialRootState

export const store = createFocusableStore(
   (state: RootState) => state, // You can use your old redux reducer here
   initialRootState,
   (window as any).__REDUX_DEVTOOLS_EXTENSION__ && (window as any).__REDUX_DEVTOOLS_EXTENSION__()
)

Actions and Updates

actionTypes()

Declare the store's actions and associated payload types. Calling this method will have absolutely no runtime effect, all it does is provide information to the TypeScript compiler.

const store = createStore({ name: 'Bob' }).actionTypes<{
   setName: string
}>()

updates()

Once action types are defined, it is possible to register type-safe updates. See immutable-lens for lens API documentation.

const store = createStore({ name: 'Bob' })
   .actionTypes<{ setName: string }>()
   // THESE FOUR CALLS TO updates() ARE ALL EQUIVALENT AND 100% TYPE SAFE
   // PICK THE ONE YOU PREFER
   .updates({
      setName: name => state => ({ ...state, name })
   })
   .updates(lens => ({
      setName: name => lens.setFields({ name })
   }))
   .updates(lens => ({
      setName: name => lens.focusPath('name').setValue(name)
   }))
   // And if really like curry...
   .updates(lens => ({
      setName: lens.focusPath('name').setValue()
   }))

Only ONE updater can be registered for a single action type. Failing to comply with that rule will result in an error:

Error: Cannot register two updaters for the same action type

When an action needs to update the state at a wider scope, move your updater to a store that has larger focus.

dispatch()

Dispatching an action can trigger an update, an epic, or a side effect.

store.dispatch({ setName: 'John' }) // Next state will be : {name: 'John'}

action()

Create an action dispatcher, which can be handily used in a React component for example.

const setName = store.action('setName')
setName('John') // Same as store.dispatch({setName: 'John'})

Consuming the state

lenrix performs reference equality checks to prevent any unnecessary re-rendering.

The store provides the properties state$ and currentState. However, we recommend you to use either pluck(), pick() or cherryPick() to select as little data as necessary. It will prevent components to re-render because an irrelevant slice of the state has changed.

state$

The store's normalized state augmented with its readonly values.

const store = createStore({ name: 'Bob' })

store.state$ // Observable<{name: string}>

currentState

Handy for testing.

const store = createStore({ name: 'Bob' })

store.currentState.name // 'Bob'

pluck()

Conceptually equivalent to focusPath(...).state$

const rootStore = createStore({
   user: {
      name: 'Bob'
   }
})

const userName$ = rootStore.pluck('user', 'name') // Observable<string>

pick()

Conceptually equivalent to focusFields().state$

const rootStore = createStore({
   counter: 0,
   user: 'Bob',
   todoList: ['Write README']
})

const pick$ = rootStore.pick('user', 'todoList') // Observable<{ user: string, todoList: string[] }>

cherryPick()

Conceptually equivalent to recompose().state$. See immutable-lens for lens API documentation.

const rootStore = createStore({
   counter: 0,
   user: {
      name: 'Bob'
   }
})

const cherryPick$ = rootStore.cherryPick(lens => ({
   // immutable-lens
   counter: lens.focusPath('counter'),
   userName: lens.focusPath('user', 'name')
})) // Observable<{ counter: number, userName: string }>

Focus

A focused store is just a proxy for the root store; there always is a single source of truth.

Most UI components only interact with a small part of the whole state tree. A focused store provides read and update access to a precise subset of the full state.

Typically, you will create a focused store for a specific page (1st level routable component). Then, if the page is functionnally rich, more stores can be derived from the page-focused store. These deep-focused stores will probably be tailored for specific components or groups of components within the page.

All these stores form a tree of stores, with the one returned by createStore() at its root. All dispatched actions are propagated to the root store, where updates are applied. The updated state then flows down the tree of stores, with values computed at some of the nodes and made available to their children.

However, that propagation stops at the stores for which the state slice in their scope has not changed.

focusPath()

const rootStore = createStore({
   user: {
      id: 123,
      name: 'John Doe'
   }
})

const userStore = rootStore.focusPath('user')
const userNameStore = userStore.focusPath('name')
// OR
const userNameStore = rootStore.focusPath('user', 'name')

userNameStore.state$ // Observable<string>

focusFields()

const rootStore = createStore({
   counter: 0,
   username: 'Bob'
})

const counterStore = rootStore.focusFields('counter')

counterStore.state$ // Observable<{counter: number}>

recompose()

Most powerful focus operator. It allows you to create state representations composed of deep properties from distinct state subtrees. See immutable-lens for lens API documentation.

const rootStore = createStore({
   a: {
      b: {
         c: {
            d: string
         }
      }
   },
   e: {
      f: {
         g: {
            h: string
         }
      }
   }
})

rootStore.recompose(lens => ({
   // immutable-lens
   d: lens.focusPath('a', 'b', 'c', 'd'),
   h: lens.focusPath('e', 'f', 'g', 'h')
})).state$ // Observable<{ d: string, h: string }>

Passing fields as readonly values

All three focus operators focusPath(), focusFields() and recompose() support passing fields as readonly values.

const rootStore = createStore({
   a: {
      b: {
         c: 'c'
      }
   },
   user: 'Bob'
})

const focusedStore = rootStore.focusPath(['a', 'b', 'c'], ['user'])
focusedStore.state$ // Observable<{ c: string, user: string }>

Note that in this example, updates registered on the focused store can't modify the user value

Computed values (synchronous)

State should be normalized, derived data should be declared as computed values. In traditional redux, you would probably use selectors for that.

lenrix performs reference equality checks to prevent unnecessary recomputation.

computeFromField()

Specify the field used for the computation to avoid useless re-computations.

createStore({ name: 'Bob', irrelevant: 'whatever' })
   .computeFromField('name', name => ({ message: 'Hello, ' + name }))
   .pick('message') // Observable<{message: string}>

computeFromFields()

Specify the fields used for the computation to avoid useless re-computations.

createStore({ name: 'Bob', irrelevant: 'whatever' })
   .computeFromFields(['name'], ({ name }) => ({ message: 'Hello, ' + name }))
   .pick('message') // Observable<{message: string}>

computeFrom()

Define computed values from state slices focused by lenses. The signature is similar to recompose() and cherryPick().

createStore({ name: 'Bob', irrelevant: 'whatever' })
   .computeFrom(
      lens => ({ name: lens.focusPath('name') }),
      ({ name }) => ({ message: 'Hello, ' + name })
   )
   .pick('message') // Observable<{message: string}>

Computed values (asynchronous)

Every synchronous value-computing operator has an asynchronous equivalent.

Note that asynchronously computed values are initially undefined. If you want them to be non-nullable, see defaultValues().

compute$()

import { map, pipe } from 'rxjs'

createStore({name: 'Bob'})
   .compute$(
      // WITH SINGLE OPERATOR...
      map(state => ({message: 'Hello, ' + state.name}))
      // ... OR WITH MULTIPLE OPERATORS
      pipe(
         map(state => state.name),
         map(name => ({message: 'Hello, ' + name}))
      )
   )
   .pick('message') // Observable<{message: string | undefined}>

computeFromField$()

import { map } from 'rxjs'

createStore({ name: 'Bob', irrelevant: 'whatever' })
   .computeFromField$(
      'name',
      map(name => ({ message: 'Hello, ' + name }))
   )
   .pick('message') // Observable<{message: string | undefined}>

computeFromFields$()

import { map } from 'rxjs'

createStore({ name: 'Bob', irrelevant: 'whatever' })
   .computeFromFields$(
      ['name'],
      map(({ name }) => ({ message: 'Hello, ' + name }))
   )
   .pick('message') // Observable<{message: string | undefined}>

computeFrom$()

import { map } from 'rxjs'

createStore({name: 'Bob', irrelevant: 'whatever'})
   .computeFrom$(
      lens => ({name: lens.focusPath('name')}),
      map(({name}) => ({message: 'Hello, ' + name}))
   .pick('message') // Observable<{message: string | undefined}>

defaultValues()

Define defaults for read-only values.

import { map } from 'rxjs'

createStore({ name: 'Bob' })
   .compute$(map(({ name }) => ({ message: 'Hello, ' + name })))
   .defaultValues({
      message: ''
   })
   .pick('message') // Observable<{message: string}>

epics()

Let an action dispatch another action, asynchronously. Since this feature is heavily inspired from redux-observable, we encourage you to go check their documentation.

import { pipe } from 'rxjs'
import { map } from 'rxjs'

createStore({name: '', message: ''})
   .actionTypes<{
      setName: string
      setMessage: string
   }>()
   .updates(lens => ({
      setName: name => lens.setFields({name}),
      setMessage: message => lens.setFields({message})
   }))
   .epics(store => ({
      // WITH SINGLE OPERATOR...
      setName: map(name => ({setMessage: 'Hello, ' + name}))
      // ... OR WITH MULTIPLE OPERATORS
      setName: pipe(
         map(name => 'Hello, ' + name),
         map(message => ({setMessage: message}))
      )
   }))

pureEpics()

Same as epics(), without the injected store instance.

...
   .pureEpics({
      setName: map(name => ({setMessage: 'Hello, ' + name}))
   })

sideEffects()

Declare synchronous side effects to be executed in response to actions. Useful for pushing to browser history, stuff like that...

createStore({ name: '' })
   .actionTypes<{
      setName: string
   }>()
   .updates(lens => ({
      setName: name => lens.setFields({ name })
   }))
   .sideEffects({
      setName: name => console.log(name)
   })

Injected store

A light version of the store is made available when using the following operators :

import { map } from 'rxjs'

createStore({ name: '', greeting: '' })
   .actionTypes<{
      setName: string
      setGreeting: string
   }>()
   .updates(lens => ({
      setName: name => lens.setFields({ name }),
      setGreeting: greeting => lens.setFields({ greeting })
   }))
   .epics(store => ({
      setName: map(() => ({ setGreeting: store.currentState.name }))
   }))

Testing

Testing an action creator, a reducer and a selector in isolation.

Man in three pieces. Legs running in place. Torso doing push-ups. Head reading.

"Looks like it’s working !"

Testing in redux usually implies testing in isolation the pieces that together form the application's state management system. It seems reasonable, since they are supposed to be pure functions.

Testing in lenrix follows a different approach. Well, technically, in most cases it would still be possible to write tests the redux way, but that's not what we had in mind when we designed it.

A lenrix store is to be considered a cohesive unit of functionality. We want to test it as a whole, by interacting with its public API. We do not want to test its internal implementation details.

As a consequence, we believe store testing should essentially consist in :

In some less frequent cases, testing might also consist in :

Test Setup

Each test should run in isolation, therefore we need to create a new store for each test. The most straightforward way is to wrap all store creation code in factory functions.

Root store

RootStore.ts

import { createStore } from 'lenrix'

export const initialRootState = {
   user: {
      name: ''
   }
}

export type RootState = typeof initialRootState

export const createRootStore = (initialState = initialRootState) =>
   createStore(initialState)

export type RootStore = ReturnType<typeof createRootStore>

RootStore.spec.ts

import 'jest'
import { createRootStore, RootStore } from './RootStore'

describe('RootStore', () => {
   let store: RootStore

   beforeEach(() => {
      store = createRootStore()
   })
})

Asserting state

Most tests should limit themselves to dispatching actions and verifying that the state has correctly updated.

The distinction between normalized state and readonly values should be kept hidden as an implementation detail. Tests should not make assumptions about a value being either readonly or part of the normalized state, as it is subject to change without breaking public API nor general behavior.

RootStore.ts

import { createStore } from 'lenrix'

export const createRootStore = (initialState = { name: '' }) =>
   createStore(initialState)
      .actionTypes<{
         setName: string
      }>()
      .updates(lens => ({
         setName: name => lens.setFields({ name })
      }))
      .compute(({ name }) => ({
         message: 'Hello, ' + name
      }))

export type RootStore = ReturnType<typeof createRootStore>

RootStore.spec.ts

import 'jest'
import { createRootStore, RootStore } from './RootStore'

describe('RootStore', () => {
   let store: RootStore

   beforeEach(() => {
      store = createRootStore()
   })

   test('updates name when "setName" dispatched', () => {
      store.dispatch({ setName: 'Bob' })

      expect(store.currentState.name).toEqual('Bob')
   })

   test('updates message when "setName" dispatched', () => {
      store.dispatch({ setName: 'Steve' })

      expect(store.currentState.message).toEqual('Hello, Steve')
   })
})

Asserting calls on dependencies

Asserting dispatched actions

Logger

0.10.0-alpha.18

6 days ago

0.10.0-alpha.7

12 months ago

0.10.0-alpha.9

12 months ago

0.10.0-alpha.8

12 months ago

0.10.0-alpha.10

12 months ago

0.10.0-alpha.12

12 months ago

0.10.0-alpha.11

12 months ago

0.10.0-alpha.14

12 months ago

0.10.0-alpha.13

12 months ago

0.10.0-alpha.16

11 months ago

0.10.0-alpha.15

11 months ago

0.10.0-alpha.17

11 months ago

0.10.0-alpha.6

1 year ago

0.10.0-alpha.3

1 year ago

0.10.0-alpha.5

1 year ago

0.10.0-alpha.4

1 year ago

0.10.0-alpha.2

1 year ago

0.10.0-alpha.1

1 year ago

0.10.0-alpha.0

1 year ago

0.9.0

2 years ago

0.8.5

5 years ago

0.8.4

5 years ago

0.8.3

5 years ago

0.8.2

5 years ago

0.8.1

5 years ago

0.8.0

5 years ago

0.7.2

5 years ago

0.6.2

5 years ago

0.6.1

5 years ago

0.6.0

5 years ago

0.5.5

5 years ago

0.5.4

5 years ago

0.5.3

5 years ago

0.5.2

5 years ago

0.5.1

6 years ago

0.5.0

6 years ago

0.4.11

6 years ago

0.4.10

6 years ago

0.4.8

6 years ago

0.4.7

6 years ago

0.4.6

6 years ago

0.4.5

6 years ago

0.4.4

6 years ago

0.4.3

6 years ago

0.4.2

6 years ago

0.4.1

6 years ago

0.4.0

6 years ago

0.3.32

6 years ago

0.3.31

6 years ago

0.3.30

6 years ago

0.3.29

6 years ago

0.3.28

6 years ago

0.3.27

6 years ago

0.3.26

6 years ago

0.3.25

6 years ago

0.3.24

6 years ago

0.3.23

6 years ago

0.3.22

6 years ago

0.3.21

6 years ago

0.3.20

6 years ago

0.3.19

6 years ago

0.3.18

6 years ago

0.3.17

6 years ago

0.3.16

6 years ago

0.3.15

6 years ago

0.3.14

6 years ago

0.3.13

6 years ago

0.3.12

6 years ago

0.3.11

6 years ago

0.3.10

6 years ago

0.3.9

6 years ago

0.3.8

6 years ago

0.3.7

6 years ago

0.3.6

6 years ago

0.3.5

6 years ago

0.3.4

6 years ago

0.3.3

6 years ago

0.3.2

6 years ago

0.3.1

6 years ago

0.3.0

6 years ago

0.2.13

6 years ago

0.2.12

6 years ago

0.2.11

6 years ago

0.2.10

6 years ago

0.2.9

6 years ago

0.2.8

6 years ago

0.2.7

6 years ago

0.2.6

6 years ago

0.2.5

6 years ago

0.2.4

6 years ago

0.2.3

6 years ago

0.2.2

6 years ago

0.2.1

6 years ago

0.2.0

6 years ago

0.1.13

6 years ago

0.1.12

6 years ago

0.1.11

6 years ago

0.1.10

6 years ago

0.1.9

6 years ago

0.1.8

7 years ago

0.1.7

7 years ago

0.1.6

7 years ago

0.1.5

7 years ago

0.1.4

7 years ago

0.1.3

7 years ago

0.1.2

7 years ago

0.1.1

7 years ago

0.1.0

7 years ago

0.0.4

7 years ago

0.0.2

7 years ago

0.0.1

7 years ago

0.0.1-alpha0

7 years ago