state-synchronizers
v1.0.1
Published
Deterministically update state based on other state
Downloads
11
Maintainers
Readme
State synchronizers 🔃
A library that makes it easy to use the idea of state synchronization for various state management solutions in a declarative manner.
Synchronized state is a type of regular state that can depend on other pieces of state, and thus, has to be updated when other pieces of state change, but can also be updated independently.
Want more information? Read this post on dev.to about synchronized state.
Need hands-on experience? Experiment with the CodeSandbox for state-synchronizers
.
Examples of synchronized state
Examples of synchronized state include:
- the current page number that a table displays, based on the number of records and page size
- any state that should be reset when other state changes
If you have used the library in a new way, feel free to create an issue telling about that or raise a PR modifying the list of examples yourself 💻
Installation
Install the library from npm:
npm install state-synchronizers
Usage
There are 2 types of functions that are used by the library:
State updaters (
(state) => synchronizedState
)State updaters apply the state synchronizations and are the building blocks of
state-synchronizers
.They are usually very specific and when written by the user, they will update a single piece of state, e.g.
const updateMaxPage = (state) => ({ ...state, maxPage: calculateMaxPage(state.recordsCount, state.pageSize), });
State updaters are used to produce state synchronizers.
State synchronizers (
(state, previousState) => synchronizedState
)State synchronizers are special types of state updaters - they apply the state synchronizations, but they can do so conditionally, because they have access to the previous state and can determine what changed.
State synchronizers will often invoke state updaters conditionally, e.g.:
const synchronizeMaxPage = (state, previousState) => { if ( state.maxPage !== previousState.maxPage || state.recordsCount !== previousState.recordsCount ) { return updateMaxPage(state); } return state; };
If you are using plain JS objects, there are utility functions that take away the boilerplate of comparing
state
andpreviousState
(seecreateStateSynchronizer
andcreateComposableStateSynchronizer
).
Single state synchronizer
Disclaimer: Not using plain JS objects for state? See Non-JS objects as state.
To write a single state synchronizer that will run your state updater function every time
one of its dependencies change, use createStateSynchronizer
:
const updateMaxPage = (state) => ({
...state,
maxPage: calculateMaxPage(state.recordsCount, state.pageSize),
});
const synchronizeMaxPage = createStateSynchronizer(updateMaxPage, [
'recordsCount',
'pageSize',
]);
// Usage:
const synchronizedState = synchronizeMaxPage(newState, previousState);
To avoid having to maintain previousState
, wrap the returned synchronizeMaxPage
in createSynchronizedStateUpdater
:
const initialState = {
// ...
};
const synchronizeMaxPage = createSynchronizedStateUpdater(
createStateSynchronizer(updateMaxPage, ['recordsCount', 'pageSize']),
initialState,
);
// Usage:
const synchronizedState = synchronizeMaxPage(newState);
Multiple state synchronizers
Often you will want to synchronize multiple pieces of state, where a piece of synchronized state can depend on other pieces of synchronized state. For this scenario, use the composition API.
The base of composition API is the ComposableStateSynchronizer
:
const composableMaxPageSynchronizer = createComposableStateSynchronizer(
// the updater
updateMaxPage,
// the piece of state that this updater synchronizes
'maxPage',
// dependencies of this piece of state
['recordsCount', 'pageSize'],
);
Then, the composable state synchronized can be combined into a single state synchronizer that will run them in the order determined by the dependencies (the dependencies will update first before a parent updates, uses topological sorting):
const mainStateSynchronizer = composeStateSynchronizers([
composableMaxPageSynchronizer,
// the synchronizer below is created similarly to composableMaxPageSynchronizer
composableCurrentPageSynchronizer,
]);
// Usage:
const synchronizedState = synchronizeMaxPage(newState, previousState);
Again, you can use createSynchronizedStateUpdater
to avoid having to maintain previousState
:
const initialState = {
// ...
};
const mainStateUpdater = createSynchronizedStateUpdater(
mainStateSynchronizer,
initialState,
);
// Usage:
const synchronizedState = synchronizeMaxPage(newState);
Non-JS objects as state
state-synchronizers
allows working with non-JS objects too, e.g. with Immutable data structures.
However, you will have to write your own state synchronizers instead of using createStateSynchronizer
and createComposableStateSynchronizer
.
To create a single state synchronizer, write it by hand:
const immutableStateSynchronizer = (state, previousState) => {
if (state.get('maxPage') !== previousState.get('maxPage')) {
return state.update('currentPage', (currentPage) =>
calculateCurrentPage(currentPage, state.maxPage),
);
}
return state;
};
It is safe the then use createSynchronizedStateUpdater
:
const immutableUpdater = createSynchronizedStateUpdater(
immutableStateSynchronizer,
initialImmutableState,
);
To use the composition API, create the composable state synchronizer by hand:
const composableImmutableStateSynchronizer = {
stateKey: 'maxPage',
dependenciesKeys: ['recordsCount', 'pageSize'],
synchronizer: immutableStateSynchronizer,
};
Note that stateKey
and dependenciesKeys
do not have to match the data in any way. They can be
arbitrary. However, they will be used to build the dependency graph in composeStateSynchronizers
,
so make sure that the names match between multiple composable state synchronizers.
For example, the state synchronizer for maxPage
should have stateKey: 'maxPage'
, and the state
synchronizer for currentPage
should have maxPage
in its array of dependenciesKeys
.
Combining composable state synchronizers is identical to the case when using plain JS objects:
const mainStateSynchronizer = composeStateSynchronizers([
composableImmutableMaxPageSynchronizer,
// the synchronizer below is created similarly to composableImmutableMaxPageSynchronizer
composableImmutableCurrentPageSynchronizer,
]);
// Usage:
const synchronizedState = synchronizeMaxPage(newState, previousState);
Synchronized reducer state
If you have an existing function that returns a modified state (e.g. a redux/React reducer) and
would like to apply state synchronization on top of it, use the withStateSynchronization
function and pass it a state updater:
const initialState = {
// ...
};
// redux's reducer
const reducer = (state = initialState, action) => {
// ...
};
const synchronizeMaxPage = createSynchronizedStateUpdater(
createStateSynchronizer(updateMaxPage, ['recordsCount', 'pageSize']),
initialState,
);
const synchronizedReducer = withStateSynchronization(synchronizeMaxPage)(
reducer,
);
// Usage:
const synchroniedState = synchronizeReducer(state, action);
synchronizedReducer
can be used in the same way reducer
would be used, so it could be passed
directly to redux.
You can also use withStateSynchronization
with a raw state updater (updateMaxPage
). Then,
updateMaxPage
would be run every time the reducer
is executed. You have control over when the
state updater runs by either specifying the raw one or the one that runs conditionally.
Usage in TypeScript
This library is TypeScript-friendly and exports its own type definitions.
It is written in TypeScript.
API
Terminology
State updater
State updater is a function that matches the following type:
type StateUpdater<S> = (state: S) => S;
State synchronizer
State synchronizer is a function that matches the following type:
type StateSynchronizer<S> = (state: S, previousState: Readonly<S>) => S;
createStateSynchronizer
createStateSynchronizer(updater, dependenciesKeys)
takes a state updater and an array
of property names. The returned state synchronizer invokes updater
when any dependency
changes. updater
is not executed when dependencies remain the same.
createSynchronizedStateUpdater
createSynchronizedStateUpdater(stateSynchronizer, initialState)
returns a state updater
that runs the state synchronizer when the state changed. It caches the previous state internally to
pass it to stateSynchronizer
.
ComposableStateSynchronizer
ComposableStateSynchronizer
is an object that matches the following interface:
interface ComposableStateSynchronizer<S, K extends keyof any = keyof S> {
/**
* The name of a piece of state that the synchronizer updates
*/
stateKey: K;
/**
* Names of pieces of state that the synchronizer depends on
*/
dependenciesKeys: K[];
synchronizer: StateSynchronizer<S>;
}
ComposableStateSynchronizer
s can be combined by composeStateSynchronizers
.
createComposableStateSynchronizer
createComposableStateSynchronizer(updater, stateKey, dependenciesKeys)
is a utility function
for creating ComposableStateSynchronizer
for plain JS objects.
composeStateSynchronizers
composeStateSynchronizers(composableStateSynchronizers)
takes an array of
ComposableStateSynchronizer
and produces a state synchronizer that runs the state synchronizers in
topological order - runs the synchronizers for children state before executing the synchronizers for
parent state.
withStateSynchronization
withStateSynchronization(stateUpdater)(functionToWrap)
wraps an existing function (e.g. a redux reducer) with a state updater.
stateUpdater
can be either a raw state updater, or one produced by
createSynchronizedStateUpdater
.
Contributing
The project is open for contributions. Feel free to create issues and PRs 🚀