0.1.9 • Published 3 years ago

compose-reducer v0.1.9

Weekly downloads
101
License
MIT
Repository
github
Last release
3 years ago

Compose reducer

build status npm version

Create reducer in an expressive (and opiniated) way.

Compose reducer has been written being used with redux in mind but it is mostly a declarative way of creating a reducer. As such it can be used in other context where reducer are helpful.

WARNING: This package is still a first draft.

Install

npm install compose-reducer

yarn add compose-reducer

Examples

Here some usage of compose-reducer

Update state

In case we would want to update a value in nested sub state, a straightforward way to do this would be a reducer like this:

const reducer = (state, action) => {
  return {
    ...state,
    field: {
      ...state.field,
      subfield: {
        ...state.field.subfield,
        value: action.payload
      }
    }
  };
};

With compose-reducer:

const reducer = composeReducer(
  setValue('field.subfield.value', (state, action) => action.payload)
);

Normalize

It is very simple to normalize collection of entities into normalized structure

const reducer = composeReducer(
  onEach(
    (state, action) => action.entities,
    // next composable reducers will be called with each entity as value

    setValue(
      (state, action) => ['entities', action.id], // dynamically compute path
      // if value resolver is not provided, action will be used as value
    ),

    // we also push each entity id into 'ids' field
    pushValue('ids', (state, action) => action.id)
  )
)

// equivalent to
(state, action) => {
  return {
    ...state,
    entities: action.entities.reduce((entities, entity) => {
      return {
        ...entities,
        [entity.id]: entity,
      }
    }, state.entities),
    ids: state.ids.concat(action.entities.map(({ id }) => id))
  }
}

Branch

Branching reducer logic given an action type

const reducer = composeReducer(
  brancheAction({
    INCREASE_COUNTER: incValue('counter', 1),
    DECREASE_COUNTER: decValue('counter', 1),
    DYNAMIC_INCREASE_COUNTER: incValue(
      'counter',
      (state, action) => action.payload
    )
  })
);

// equivalent to
(state, action) => {
  switch (action.type) {
    case 'INCREASE_COUNTER':
      return { ...state, counter: (state.counter || 0) + 1 };
    case 'INCREASE_COUNTER':
      return { ...state, counter: (state.counter || 0) - 1 };
    case 'DYNAMIC_INCREASE_COUNTER':
      return { ...state, counter: (state.counter || 0) + action.payload };
  }
};

Api

WARNING: Api is a first draft and may change in the future.

composeReducer

This function create a reducer with given pipeline of composable reducer

  composeReducer(...composableReducers: ComposableReducer[]): (state: State, action: Action) => State

Composable reducer will be applied in given order.

import { composeReducer, incValue } from 'compose-reducer';

const reducer = composeReducer(
  incValue('counter1', 1), // increase counter1 field by 1
  incValue('counter2', 10), // then increase counter2 field by 10
  incValue('counter1', 5) // then increase counter1 field by 5
);

const initalState = { counter1: 0, counter2: 2 };
reducer(initialState); // { counter1: 6, counter2: 12 }

Composable Reducer

Composable reducer are meant to be used within composeReducer.

Value composable reducer

initState

initState(valueResolver: (state: any, action: any, context: object) => any | any): ComposableReducer
const reducer = composeReducer(initState({ counter: 0 }));
reducer(undefined); // { counter: 0 }

// However
reducer(null); // null
reducer(0); // 0
reducer(''); // ''
reducer(false); // false

setValue

Set resolved value at resolved path.

Resolved path may be a static string or a function that will compute path given state and action.

Resolved value may be a static value (non function value) or a function that will compute path given state and action.

setValue(
  pathResolver: string | string[] | ((state: any, action: any, context: object) => string | string[])
  valueResolver?: (state: any, action: any, context: object) => any | any
): ComposableReducer
import { composeReducer, setValue } from 'compose-reducer';

const reducer = composeReducer(setValue('field.nestedField', 'hello world'));

const initialState = {};
reducer(initialState); // { field: { nestedField: 'hello world' } }

//  equivalent to (dynamic path)
composeReducer(setValue((state, action) => 'field.nestedField', 'hello world'));

// equivalent to (dynamic value)
composeReducer(setValue('field.nestedField', (state, action) => 'hello world'));

// In case value resolver is not provided, value will be resolved to given action
const setSizeReducer = composeReducer(setValue('size'));
setSizeReducer({ size: 0 }, 10); // { size: 10 }

unsetValue

unsetValue(
  pathResolver: string | (state: any, action: any, context: object) => string
): ComposableReducer
import { composeReducer, unsetValue } from 'compose-reducer';

const reducer = composeReducer(unsetValue('entities.1'));

const initalState = { entites: { 1: { id: '1' }, 42: { id: '42' } } };
reducer(initialState); // { entites: { 42: { id: '42' } } }

// equivalent to (dynamic path)
const reducer = composeReducer(unsetValue((state, action) => 'entites.1'));

incValue

incValue(
  pathResolver: string | string[] | ((state: any, action: any, context: object) => string | string[])
  incValueResolver: number | (state: any, action: any, context: object) => number
): ComposableReducer
import { composeReducer, incValue } from 'compose-reducer';

const reducer = composeReducer(incValue('counter', 1));
const initialState = { counter: 0 };
reducer(initialState); // { counter: 1 }

// equivalent to (dynamic path)
composeReducer(incValue((state, action) => 'counter', 1));

// equivalent to (dynamic value)
composeReducer(incValue('counter', (state, action) => 1));

// equivalent to (dynamic path and value)
composeReducer(
  incValue(
    (state, action) => 'counter',
    (state, action) => 1
  )
);

decValue

decValue(
  pathResolver: string | string[] | ((state: any, action: any, context: object) => string | string[])
  decValueResolver: number | (state: any, action: any, context: object) => number
): ComposableReducer
import { composeReducer, decValue } from 'compose-reducer';

const reducer = composeReducer(decValue('counter', 1));
const initialState = { counter: 0 };
reducer(initialState); // { counter: -1 }

// equivalent to (dynamic path)
composeReducer(decValue((state, action) => 'counter', 1));

// equivalent to (dynamic value)
composeReducer(decValue('counter', (state, action) => 1));

// equivalent to (dynamic path and value)
composeReducer(
  decValue(
    (state, action) => 'counter',
    (state, action) => 1
  )
);

pushValue

pushValue(
  pathResolver: string | string[] | ((state: any, action: any, context: object) => string | string[])
  pushedValueResolver: (state: any, action: any, context: object) => any | any
): ComposableReducer
import { composeReducer, pushValue } from 'compose-reducer';

const reducer = composeReducer(pushValue('array', 10));
const initialState = { array: null };
const nextState = reducer(initialState); // { array: [10] }
reducer(nextState); // { array: [10, 10] }

// equivalent to (dynamic path)
composeReducer(pushValue((state, action) => 'array', 10));

// equivalent to (dynamic value)
composeReducer(pushValue('array', (state, action) => 10));

// equivalent to (dynamic path and value)
composeReducer(
  pushValue(
    (state, action) => 'array',
    (state, action) => 10
  )
);

pushValues

pushValues(
  pathResolver: string | string[] | ((state: any, action: any, context: object) => string | string[])
  pushedValuesResolver: (state: any, action: any, context: object) => any[] | any
): ComposableReducer
import { composeReducer, pushValues } from 'compose-reducer';

const reducer = composeReducer(pushValues('array', [1, 2, 3]));
const initialState = { array: null };
const nextState = reducer(initialState); // { array: [1, 2, 3 }
reducer(nextState); // { array: [1, 2, 3, 1, 2, 3] }

// equivalent to (dynamic path)
composeReducer(pushValues((state, action) => 'array', [1, 2, 3]));

// equivalent to (dynamic value)
composeReducer(pushValues('array', (state, action) => [1, 2, 3]));

// equivalent to (dynamic path and value)
composeReducer(
  pushValues(
    (state, action) => 'array',
    (state, action) => [1, 2, 3]
  )
);

popValues

popValues(
  pathResolver: string | string[] | ((state: any, action: any, context: object) => string | string[])
  popedValueIndexesResolver: number | number[] | ((state: any, action: any, context: object) => number| number[])
): ComposableReducer
import { composeReducer, popValues } from 'compose-reducer';

// reducer that will remove elem at index 1 of field 'array'
const reducer = composeReducer(popValues('array', 1));
reducer({ array: ['hello', 'world', 'hel', 'wor'] }); // { array: ['hello', 'hel', 'wor']}
// ignore if out of range
reducer({ array: [] }); // { array: [] }

// reducer that will remove elem at index 1, 2 and 3 of field 'array'
const reducer2 = composeReducer(popValues('array', [1, 2, 3]));
reducer2({ array: ['hello', 'world', 'hel', 'wor'] }); // { array: ['hello']}
// ignore out of range indexes
reducer2({ array: ['hello', 'world'] }); // { array: ['hello']}

mergeValue

mergeValue(
  pathResolver: string | string[] | ((state: any, action: any, context: object) => string | string[]),
  objectResolver: object | ((state: any, action: any, context: object) => object)
): ComposableReducer

Flow composable reducer

branch

branch(
  predicate: (state: any, action: any, context: object) => boolean
  trueReducer?: ComposableReducer
  falseReducer?: ComposableReducer
): ComposableReducer

predicate

Predicate take a predicate function and put resolved value into context of all given composable reducers. You can provide a context field name as first argument to specify which field the value should be put at. (By default a symbol is used)

predicate is mainly designed to be used with ifTrue and ifFalse. It allow branching like branch in a more expressive way.

predicate(
  predicateResolver: (state: any, action: any, context: object) => boolean,
  ...composableReducers: ComposableReducer[]
): ComposableReducer
predicate(
  contextFieldName: string,
  predicateResolver: (state: any, action: any, context: object) => boolean,
  ...composableReducers: ComposableReducer[]
): ComposableReducer
import {
  composeReducer,
  predicate,
  ifTrue,
  ifFalse,
  setValue
} from 'compose-reducer';

const reducer = composeReducer(
  predicate(
    (state, action) => action.isTrue,
    ifTrue(setValue(null, 'isTrue')),
    ifFalse(setValue(null, 'isFalse'))
  )
);

reducer(null, { isTrue: true }); // 'isTrue'
reducer(null, { isTrue: false }); // 'isFalse'

ifTrue

Call given composable reducers if context field is true.

You can provide context field name used as predicate (by default use same symbol as predicate as context field)

ifTrue(...composableReducers: ComposableReducer[]): ComposableReducer
ifTrue(contextFieldName: string, ...composableReducers: ComposableReducer[]): ComposableReducer

ifFalse

Call given composable reducers if context field is false.

You can provide context field name used as predicate (by default use same symbol as predicate as context field)

ifFalse(...composableReducers: ComposableReducer[]): ComposableReducer
ifFalse(contextFieldName: string, ...composableReducers: ComposableReducer[]): ComposableReducer

branchAction

branchAction(
  ...branches: Map<string, ComposableReducer | ComposableReducer[]>
               | [...(string | (state: any, action: any, context: object) => bool), ComposableReducer]
): ComposableReducer
import {
  composeReducer,
  branchAction,
  incValue,
  decValue
} from 'compose-reducer';

const reducer = composeReducer(
  branchAction({
    INC_COUNTER: incValue('counter', 1),
    DEC_COUNTER: decValue('counter', 1)
  })
);

const initialState = { counter: 0 };
reducer(initialState, { type: 'INC_COUNTER' }); // { counter: 1 }
reducer(initialState, { type: 'DEC_COUNTER' }); // { counter: -1 }

// equivalent to
composeReducer(
  branchAction(
    ['INC_COUNTER', incValue('counter', 1)],
    ['DEC_COUNTER', decValue('counter', 1)]
  )
);

// equivalent to
composeReducer(
  branchAction(
    [(state, action) => action.type === 'INC_COUNTER', incValue('counter', 1)],
    [(state, action) => action.type === 'DEC_COUNTER', decValue('counter', 1)]
  )
);

Array branching may have a liste of action type or predicate before the actual reducer Type and predicate of a same stage will apply reducer only once

const reducer = composeReducer(
  branchAction([
    (state, action) => action.type === 'INC_COUNTER',
    'INC_COUNTER',
    'INCREASE',
    incValue('counter', 1)
  ])
);
const initalState = { counter: 0 };
reducer(initialState, 'INC_COUNTER'); // { counter: 1 }
reducer(initialState, 'INCREASE'); // { counter: 1 }

In case predicate match in different stages, each reducer will be applied

const reducer = composeReducer(
  branchAction(
    [(state, action) => action.type === 'INC_COUNTER', incValue('counter', 1)],
    ['INC_COUNTER', 'INCREASE', incValue('counter', 1)]
  )
);

const initalState = { counter: 0 };
reducer(initialState, { type: 'INC_COUNTER' }); // { counter: 2 }
reducer(initialState, { type: 'INCREASE' }); // { counter: 1 }

mapAction

Apply all given composable reducer with resolved value as action.

This may be usefull to map input state/action for easier reducer reusability.

mapAction(
  actionResolver: any | (state: any, action: any, context: object) => any,
  ...composableReducers: ComposableReducer[]
): ComposableReducer
import { composeReducer, mapAction, setValue } from 'compose-reducer';

const reducer = composeReducer(
  mapAction(
    (state, action) => action.payload,
    setValue('field') // received action will be field 'payload' of initial action
  )
);

reducer({ field: 0 }, { payload: 100 }); // { field: 100 }

mapActions

Apply all given composable reducers on each resolved action

mapActions(
  actionResolver: any | (state: any, action: any, context: object) => any[],
  ...composableReducers: ComposableReducer[]
): ComposableReducer
import {
  composeReducer,
  mapActions,
  setValue,
  pushValue
} from 'compose-reducer';

const reducer = composeReducer(
  mapActions(
    (state, action) => action.items,
    setValue((state, action) => ['entities', action.id]),
    pushValue('ids', (state, action) => action.id)
  )
);

reducer(
  { entities: {}, ids: [] },
  {
    items: [
      { id: 1, name: 'item 1' },
      { id: 2, name: 'item 2' },
      { id: 3, name: 'item 3' }
    ]
  }
);
// {
//   entities: { 1: { id: 1, name: 'item 1' }, 2: { id: 2, name: 'item 2' }, 3: { id: 3, name: 'item 3' } } }
//   ids: [1, 2, 3]
// }

onEach

Alias of withActions

import { composeReducer, onEach, setValue, pushValue } from 'compose-reducer';

const reducer = composeReducer(
  onEach(
    (state, action) => action.items,
    setValue((state, action) => ['entities', action.id]),
    pushValue('ids', (state, action) => action.id)
  )
);

reducer(
  { entities: {}, ids: [] },
  {
    items: [
      { id: 1, name: 'item 1' },
      { id: 2, name: 'item 2' },
      { id: 3, name: 'item 3' }
    ]
  }
);
// {
//   entities: { 1: { id: 1, name: 'item 1' }, 2: { id: 2, name: 'item 2' }, 3: { id: 3, name: 'item 3' } } }
//   ids: [1, 2, 3]
// }

Context

In some cases it is convenient to be able to reuse a previously computed value in multiple sub reducer. This is possible through context.

withContext

Add some values to context

Added values are scoped, only accessible in sub (given) composableReducers

withContext(
  contextResolver: (state: any, action: any, context: object) => object | object,
  ...composableReducers: ComposableReducer[]
): ComposableReducer
import { composeReducer, withContext, setValue, pushValue } from 'compose-reducer';

const reducer = composeReducer(
  withContext(
    (state, action) => ({
      id: `${action.payload.type}::${action.payload.id}`,
    }),
    setValue(
      (state, action, context) => ['entities', context.id],
      (state, action) => action.payload,
    ),
    pushValue(
      (state, action) => ['ids', action.payload.type],
      (state, action, context) => context.id,,
    )
  ),
)

const initialState = { entites: { 'car:1': { id: 1, type: 'car', name: '#001'  } }, ids: { car: ['car:1'] } }
reducer(initialState, { payload: { type: 'bus', id: 1, name: '#001' } })
// {
//    entities: { 'car:1': { ... }, 'bus:1': { type: 'bus', id: 1, name: '#001' } }
//    ids: { car: ['car:1'], bus: ['bus:1'] }
// }

at

at update a builtin context variable that is used as root path for each provided value composable reducer

Resolved path is scoped and added to current path, only sub (given) composable reducers will work on new current path

at(
  pathResolver: string | (state: any, action: any, context: object) => string,
  ...composableReducers: ComposableReducer[]
): ComposableReducer
import { composeReducer, at, incValue } from 'compose-reducer';

const reducer = composeReducer(
  at(
    'field',
    incValue('counter', 10) // will be applied to 'field'
    at(
      'subfield',
      incValue('counter', 200), // will be applied to 'field.subfield'
    )
  ),
  incValue('counter', 5) // is not affected by at
)

reducer({ counter: 0, field: { counter: 0, subfield: { counter: 0 } } }) // { counter: 5, field: { counter: 10, subfield: { counter: 200 } }  }

provideResolver

WARNING: experimental

To facilitate reducer reusability s allow very simple dependency injection in combinaison with injectResolver.

Like context, provided reducers are only available to provided sub reducers. In case a reducer has been previously provided, it will be overridden.

provideResolver(reducerMap: { [reducerKey: string]: ComposableReducer }, ...composableReducers: ComposableReduer[]): ComposableReducer
const reducer = composeReducer(
  provideResolver(
    {
      // Expected action is to item itself
      updateItem: setValue((state, action) => ['items', action.id])
    },
    branchAction({
      // map action to respect signature
      UPDATE_ITEM: mapAction(
        (state, action) => action.item,
        injectResolver('updateItem')
      ),
      UPDATE_ITEMS: onEach(
        (state, action) => action.items,
        injectResolver('updateItem')
      )
    })
  )
);

reducer({ items: {} }, { type: 'UPDATE_ITEM', item: { id: 'item_1' } }); // { items: { 'item_1': { id: 'item_1' } } }

reducer(
  { items: {} },
  {
    type: 'UPDATE_ITEMS',
    items: [{ id: 'item_1' }, { id: 'item_2' }, { id: 'item_3' }]
  }
); // { items: { 'item_1': { id: 'item_1' }, 'item_2': { id: 'item_2' } } }

Obviously simpliest way to reuse reducer would be to create a variable.

const updateItem = setValue((state, action) => ['items', action.id]);

const reducer = composeReducer(
  branchAction({
    // map action to respect signature
    UPDATE_ITEM: mapAction((state, action) => action.item, updateItem),
    UPDATE_ITEMS: onEach((state, action) => action.items, updateItem)
  })
);

injectResolver

injectResolver(reducerKey: string): ComposableReducer

Utils

composable

Create a composable reducer from a pipeline of composable reducers

This may be usefull to create reusable reducer pipeline or split reducer logic

composable(...composableReducers: ComposableReducer[]): ComposableReducer

Using composable with at allow to separate substate reducing logic. (For redux users, this is a way to simulate combineReducers)

import {
  composable,
  branchAction,
  pushValue,
  setValue,
  composeReducer,
  at,
  initState
} from 'composable-reducer';

export const reduceTodos = composable(
  initState([]),
  branchAction({
    ADD_TODO: pushValue(null, (state, action) => ({ text: action.text }))
  })
);

export const reduceVisibility = composable(
  initState('SHOW_ALL'),
  branchAction({
    SET_VISIBILITY_FILTER: setValue(null, (state, action) => action.visibility)
  })
);

const rootReducer = composeReducer(
  at('todos', reduceTodos),
  at('visibility', reduceVisibility)
);

Value resolver

Basic high order resolver. They can be used outside of composeReducer.

type ValueResolver = (state, action) => any;

getState

Resolve value from state using resolved path

getState<T>(
  ...paths: (String | String[] | (state, action) => T)[]
): ValueResolver<T>
import { getState } from 'compose-reducer';

const state = {
  value: 'hello',
  field: {
    subField: 'world'
  }
};
getState('value')(state); // 'hello'

// nested path as string
getState('field.subField')(state); // 'world'

// nested path as array
getState(['field', 'subField'])(state); // 'world'

// path as multiple arguments
getState('field', 'subField')(state); // 'world'

// resolve path element dynamically
getState('field', (state, action) => action.field)(state, {
  field: 'subField'
}); // 'world'

getAction

Resolve value from action using resolved path

getAction<T>(
  ...paths: (String | String[] | (state, action) => T)[]
): ValueResolver<T>
import { getAction } from 'compose-reducer';

const action = {
  value: 'hello',
  field: {
    subField: 'world'
  }
};

getAction('value')(null, action); // 'hello'

// nested path as string
getAction('field.subField')(null, action); // 'world'

// nested path as array
getAction(['field', 'subField'])(null, action); // 'world'

// path as multiple arguments
getAction('field', 'subField')(null, action); // 'world'

// resolve path element dynamically
getAction('field', (state, action) => state)('subField', action); // 'world'

array

Resolve an array

array<T>(
  ...paths: (T | (state, action) => T)[]
): ValueResolver<Array<T>>
import { array, getAction } from 'compose-reducer';

array(1, 2)(); // [1, 2]
array('field', 'subField'); // ['field', 'subField']
array('field', getAction('field'))(null, { field: 'subField' }); // ['field', 'subField']

object

Resolve an object

object<T>(obj: T): {
  [P in keyof T]: T extends Function ? ReturnType<T> : T
}
import { object } from 'compose-reducer';

object({
  id: getAction('id')
  name: 'hello',
  count: (state, action) => state.count
})({
  count: 1,
}, {
  id: 1234
});
// {
//   id: 1234,
//   name: 'hello'
//   count: 1
// }

Note: if the object mapping contain dynamic field value resolver, then a new object will be created at each resolve However if resolved object is total static, it will always resolve to the same object

import { object } from 'compose-reducer';

const resolver = object({
  hello: () => 'world'
});

const state1 = resolver(); // { hello: 'world' }
const state2 = resolver(); // { hello: 'world' }
state1 === state2; // false
import { object } from 'compose-reducer';

const resolver = object({
  hello: 'world'
});

const state1 = resolver(); // { hello: 'world' }
const state2 = resolver(); // { hello: 'world' }
state1 === state2; // true

compute

Resolve multiple values and compute them into a single value

import { compute } from 'compose-reducer';

compute(
  (state, action) => state,
  ' ',
  (state, action) => action,
  (arg1, arg2, arg3) => arg1 + arg2 + arg3
)('hello', 'world'); // 'hello world'

comparison

type NumberResolver = number | (state, action) => number;
eq(value: NumberResolver, other: NumberResolver): ValueResolver<boolean>
gt(value: NumberResolver, other: NumberResolver): ValueResolver<boolean>
gte(value: NumberResolver, other: NumberResolver): ValueResolver<boolean>
lt(value: NumberResolver, other: NumberResolver): ValueResolver<boolean>
lte(value: NumberResolver, other: NumberResolver): ValueResolver<boolean>

min/max

type NumberResolver = number | (state, action) => number;
min(...NumberResolver[]): ValueResolver<number>
max(...NumberResolver[]): ValueResolver<number>
0.1.9

3 years ago

0.1.8

3 years ago

0.1.7

3 years ago

0.1.6

3 years ago

0.1.5

4 years ago

0.1.4

4 years ago

0.1.3

4 years ago

0.1.2

4 years ago

0.1.1

4 years ago

0.1.0

4 years ago