Skip to content

Gelio/state-synchronizers

Repository files navigation

State synchronizers 🔃

npm version License: MIT Build Status Monthly downloads npm bundle size

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.

  1. Examples of synchronized state
  2. Installation
  3. Usage
  4. API
  5. Contributing

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:

  1. 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.

  2. 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 and previousState (see createStateSynchronizer and createComposableStateSynchronizer).

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>;
}

ComposableStateSynchronizers 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 🚀