Skip to content

Commit

Permalink
Redux: Create two new util helpers for creating reducers (#9991)
Browse files Browse the repository at this point in the history
* Redux: Create two new util helpers for creating reducers

Creates two new helpers to be used in #9914 for managing state and
creating reducers that are idiomatic to Redux.

 - `keyedReducer()` for managing collections of keyed items
 - `withValidation()` for wrapping reducers in state validation
  • Loading branch information
dmsnell authored Dec 13, 2016
1 parent 5474616 commit a8171b2
Show file tree
Hide file tree
Showing 3 changed files with 384 additions and 2 deletions.
108 changes: 108 additions & 0 deletions client/state/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,111 @@ function extended() {
dispatch( extended() );
// Dispatches two actions `{ type: 'example', extended: true }`
```

### keyedReducer( keyName, reducer )

Sometimes we have a collection of properties that represent some object or data item and our reducers contain a collection of those items.
For example, we may have a list of widgets for a site, but our reducer keeps a collection of those sites.

It's fairly common to see reducers built like this:

```js
const widgetCount = ( state = {}, action ) => {
if ( ADD_WIDGET === action.type ) {
return {
...state,
[ action.siteId ]: state[ action.siteId ] + 1,
}
}

return state;
}
```

Notice that the reducer _wants_ to operate on a simple integer, but because it has to store a collection of these things, identified by which `siteId` it belongs to, we have a complicated initial state and confusing syntax on the return value.

What if we could write them like this instead…

```js
const widgetCount = ( state = 0, action ) => {
if ( ADD_WIDGET === action.type ) {
return state + 1;
}

return state;
}
```

…and somehow it would know to assign this to the proper site?

This utility helper provides the glue for us to allow this style of reducer composition. It creates a reducer for a collection of items which are identified by some key. In our case, we can create it with the `siteId` of the action as the key.

```js
export default keyedReducer( 'siteId', widgetCount );
```

This will automatically search for a `siteId` property in the action and dispatch the update to the appropriate item in the collection.
Unlike building a reducer without this helper, the updates will only apply to the item in the collection with the given key.

By using this helper we can keep our reducers small and easy to reason about.
They automatically benefit from the way `combineReducers` will immutably update the state, wherein if no changes actually occur then no updates will follow.
We are left with simple update expressions that are decoupled from the rest of the items in the collection.
We are provided the opportunity to make straightforward tests without complicated mocks.

#### Example

```js
const age = ( state = 0, action ) =>
GROW === action.type
? state + 1
: state

const title = ( state = 'grunt', action ) =>
PROMOTION === action.type
? action.title
: state

const userReducer = combineReducers( {
age,
title,
} )

export default keyedReducer( 'username', userReducer )

dispatch( { type: GROW, username: 'hunter02' } )

state.users === {
hunter02: {
age: 1,
title: 'grunt',
}
}
```

### withSchemaValidation( schema, reducer )

When Calypso boots up it loads the last-known state out of persistent storage in the browser.
If that state was saved from an old version of the reducer code it could be incompatible with the new state model.
Thankfully we are given the ability to validate the schema and conditionally load the persisted state only if it's valid.
This can be done by passing a schema into a call to `createReducer()`, but sometimes `createReducer()` provides more abstraction than is necessary.

This helper produces a new reducer given an original reducer and schema.
The new reducer will automatically validate the persisted state when Calypso loads and reinitialize if it isn't valid.
It is in most regards a lightweight version of `createReducer()`.

#### Example

```js
const ageReducer = ( state = 0, action ) =>
GROW === action.type
? state + 1
: state

const schema = { type: 'number', minimum: 0 }

export const age = withSchemaValidation( schema, ageReducer )

ageReducer( -5, { type: DESERIALIZE } ) === -5
age( -5, { type: DESERIALIZE } ) === 0
age( 23, { type: DESERIALIZE } ) === 23
```
126 changes: 124 additions & 2 deletions client/state/test/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,21 @@ describe( 'utils', () => {
} ),
actionSerialize = { type: SERIALIZE },
actionDeserialize = { type: DESERIALIZE };
let extendAction, createReducer, reducer;
let createReducer;
let extendAction;
let keyedReducer;
let reducer;
let withSchemaValidation;

useMockery( ( mockery ) => {
mockery.registerMock( 'lib/warn', noop );

( { extendAction, createReducer } = require( 'state/utils' ) );
( {
createReducer,
extendAction,
keyedReducer,
withSchemaValidation,
} = require( 'state/utils' ) );
} );

describe( 'extendAction()', () => {
Expand Down Expand Up @@ -235,4 +244,117 @@ describe( 'utils', () => {
expect( state ).to.eql( [ 0, 1 ] );
} );
} );

describe( '#keyedReducer', () => {
const grow = name => ( { type: 'GROW', name } );

const age = ( state = 0, action ) =>
'GROW' === action.type
? state + 1
: state;

const prevState = deepFreeze( {
Bonobo: 13,
} );

it( 'should only accept string-type key names', () => {
expect( () => keyedReducer( null, age ) ).to.throw( TypeError );
expect( () => keyedReducer( undefined, age ) ).to.throw( TypeError );
expect( () => keyedReducer( [], age ) ).to.throw( TypeError );
expect( () => keyedReducer( {}, age ) ).to.throw( TypeError );
expect( () => keyedReducer( true, age ) ).to.throw( TypeError );
expect( () => keyedReducer( 10, age ) ).to.throw( TypeError );
expect( () => keyedReducer( 15.4, age ) ).to.throw( TypeError );
expect( () => keyedReducer( '', age ) ).to.throw( TypeError );
expect( () => keyedReducer( 'key', age ) ).to.not.throw( TypeError );
} );

it( 'should only accept a function as the reducer argument', () => {
expect( () => keyedReducer( 'key', null ) ).to.throw( TypeError );
expect( () => keyedReducer( 'key', undefined ) ).to.throw( TypeError );
expect( () => keyedReducer( 'key', [] ) ).to.throw( TypeError );
expect( () => keyedReducer( 'key', {} ) ).to.throw( TypeError );
expect( () => keyedReducer( 'key', true ) ).to.throw( TypeError );
expect( () => keyedReducer( 'key', 10 ) ).to.throw( TypeError );
expect( () => keyedReducer( 'key', 15.4 ) ).to.throw( TypeError );
expect( () => keyedReducer( 'key', '' ) ).to.throw( TypeError );
expect( () => keyedReducer( 'key' ) ).to.throw( TypeError );
expect( () => keyedReducer( 'key', () => {} ).to.not.throw( TypeError ) );
} );

it( 'should create keyed state given simple reducers', () => {
const keyed = keyedReducer( 'name', age );
expect( keyed( undefined, grow( 'Calypso' ) ) ).to.eql( {
Calypso: 1
} );
} );

it( 'should only affect the keyed item in a collection', () => {
const keyed = keyedReducer( 'name', age );
expect( keyed( prevState, grow( 'Calypso' ) ) ).to.eql( {
Bonobo: 13,
Calypso: 1,
} );
} );

it( 'should skip if no key is provided in the action', () => {
const keyed = keyedReducer( 'name', age );
expect( keyed( prevState, { type: 'GROW' } ) ).to.equal( prevState );
} );

it( 'should handle falsey keys', () => {
const keyed = keyedReducer( 'name', age );
expect( keyed( { [ 0 ]: 10 }, grow( 0 ) ) ).to.eql( { '0': 11 } );
} );

it( 'should handle coerced-to-string keys', () => {
const keyed = keyedReducer( 'name', age );
expect( keyed( { '10': 10 }, grow( '10' ) ) ).to.eql( { '10': 11 } );
expect( keyed( { [ 10 ]: 10 }, grow( '10' ) ) ).to.eql( { '10': 11 } );
expect( keyed( { [ 10 ]: 10 }, grow( 10 ) ) ).to.eql( { '10': 11 } );
expect( keyed( { '10': 10 }, grow( 10 ) ) ).to.eql( { '10': 11 } );
} );

it( 'should return without changes if no actual changes occur', () => {
const keyed = keyedReducer( 'name', age );
expect( keyed( prevState, { type: 'STAY', name: 'Bonobo' } ) ).to.equal( prevState );
} );

it( 'should not initialize a state if no changes and not keyed (simple state)', () => {
const keyed = keyedReducer( 'name', age );
expect( keyed( prevState, { type: 'STAY', name: 'Calypso' } ) ).to.equal( prevState );
} );
} );

describe( '#withSchemaValidation', () => {
const load = { type: DESERIALIZE };
const normal = { type: 'NORMAL' };
const schema = {
type: 'number',
minimum: 0,
};

const age = ( state = 0, action ) =>
'GROW' === action.type
? state + 1
: state;

it( 'should invalidate DESERIALIZED state', () => {
const validated = withSchemaValidation( schema, age );

expect( validated( -5, load ) ).to.equal( 0 );
} );

it( 'should not invalidate normal state', () => {
const validated = withSchemaValidation( schema, age );

expect( validated( -5, normal ) ).to.equal( -5 );
} );

it( 'should validate initial state', () => {
const validated = withSchemaValidation( schema, age );

expect( validated( 5, load ) ).to.equal( 5 );
} );
} );
} );
Loading

0 comments on commit a8171b2

Please sign in to comment.