restato

A minimal and flexible store inspired by Redux

Usage no npm install needed!

<script type="module">
  import restato from 'https://cdn.skypack.dev/restato';
</script>

README

restato

A minimal but very flexible and powerful store inspired by Redux.

Install

restato can be installed using either npm or yarn

npm install restato
yarn add restato

Usages

Store

States are managed by a store.

New store can be created by calling createStore with initial state.

import { createStore } from "restato";

// initState object will be deeply frozen after createStore() returns;
// any mutation attempts will fail, either silently or by throwing a
// TypeError exception (most commonly, but not exclusively, when in strict mode).
const initState = {};
const store = createStore(initState);
const { dispatch, select, subscribe } = store;

Access state

State can't be accessed directly, need to

  • dispatch an action to mutate state
  • use a selector to read state

Action

Action is used to make changes to the state.

An action is just a function that is called by the store with the current state as the first argument.

State passed in can be mutated in place. Store will manage the mutations and apply them to a copy of the state when it's done.

Actions are dispatched asynchronously and multiple actions may be batched and be processed together.

It's recommened to group actions by feature and put into a folder. Each action should have a name which will be used as the type by the Redux DevTools middleware. See below middleware section for details.

// get dispatch form a store object
const { dispatch } = store;

// a simple action
// first parameter is always state
// extra parameter may be passed if specified when calling dispatch
function updateFoo(state, value) => {
  state.foo = value;
}
// disatch action updateFoo
// action must be the first argument
// extra arguments will be passed through to action as is
dispatch(updateFoo, "bar");


// action can also be async
async function asyncAction(state) {
  state.isFetching = true;

  // send request to server
  doFetch().then(value => {
    // update state after response comes back
    state.foo = value;
    state.isFetching = false;
  });
}
dispatch(asyncAction);

Since action is just a normal function, an action can call another action just like any function. This makes it easy to reuse codes.

function actionOne(state) {
  // do something
}

function actionTwo(state) {
  ... // stuff
  actionOne(state);
  ... // more stuff
}

dispatch(actionTwo);

Note, when assign object and other container values like array, Map and Set to state or one of its offsprings in action, value will be frozen and no furthur mutations can be made on it directly. All changes must be done via the reference get from state

function action(state) {
  const obj = { key: "value" };

  // after this line, object will be frozen, variable obj can not be used any more
  // any mutation attempts will fail, either silently or by throwing a
  // TypeError exception (most commonly, but not exclusively, when in strict mode).
  state.obj = obj;

  const key = obj.key; // read is still ok
  obj.foo = "bar"; // either ignore silently or throw error

  state.obj.foo = "bar"; // Ok

  // use a reference returned from state
  const stateObj = state.obj;
  stateObj.foo = "bar"; // Ok

}

Selector

Selector is used to read state out of store.

Similar to action, a selector is just a function that is called with current state as the only parameter.

Selecor should only read from state. Everything returned from selector is frozen, any mutation attempts will fail, either silently or by throwing a TypeError exception (most commonly, but not exclusively, when in strict mode).

// get dispatch form a store object
const { select, subscribe } = store;

const selector = (state) => state.foo;

// call select to select once immediately
// return value is what's returned from selector
const foo = select(selector);

// can also subscribe the selector
// selector will be called after state is changed by action
// calling the returned function will unsubscribe selector
const unsub = subscribe(selector);

unsub(); // selector won't be called on state changes

Middlewares

Middleware can be used to tap into the action dispatching and state mutating flow. For example, to delay the dispatching, or even stop the dispatching, etc.

Middlewares can be registered with store.addMiddlewares(middleware1, middleware1, ...) method.

A middleware factory function should be passed to addMiddlewares. This function will be called with a store that has getState(), setState(newState) and dispatch(action, ...args) methods. It should return an object with 3 methods: execute(action, args, next), asyncExecuted(action, args, next) and destroy(). All 3 methods are optional and is called at various point of an action's lifecyle.

The middlewares are applied in the same order as they are added. The first one will be given the original action and args passed to store.dispatch(); it is expected to call next(action, args), with either the original action & args or a different one, to pass the control to next middleware; this is repeated until the last middleware where the action will be executed when next(action, args) is called.

const loggerMiddleware = ({
  getState, // get current state
  setState, // set new state immediately
  dispatch  // dispatch an action as normal
}) => {
  return {
    // execute is called when an action is about to be executed
    // this can be an async function if needed to say delay the dispatch
    execute(action, args, next) {
      console.log("pre dispatch", action.name, getState());

      // passing action and args to next middleware
      // action dispatching will stop if next() is not called
      // can just pass the provided action & args or a new action as needed
      // aciton will be executed if this is the last middle in the chain
      next(action, args);

      console.log("post dispatch", action.name, getState());
    },

    // asyncExecuted is called after state is mutated by an action's async codes
    // for example, in the callback passed to "promise.then".
    // calling next() synchronously indicates mutations should be committed;
    // mutations will be discarded if next()
    // - is not called or
    // - is called asynchronously later
    asyncExecuted(action, args, next) {
      console.log("async operation");

      // calls next middleware in the chain
      // mutations will be discarded if next() is not called
      next();
    }

    // called when store is being destroyed
    destroy() {
      // do any clean up required
    }
  };
};

store.addMiddlewares(logger);

Dispatch additional actions

Middlewares can dispatch additional actions during initialisation, in execute or asyncExecuted by calling dispatch() on the passed in store object. These actions will go through the middleware chain like a normal action. Since actions are dispatched asynchronously, the order of dispatching is indeterministic.

Redux DevTools

A middleware is provided for connecting to Redux DevTools extension.

import reduxDevTools from "restato/middlewares/redux-devtools";
// below if bundler doesn't support Subpath exports
// see https://nodejs.org/api/packages.html#packages_subpath_exports
//
// import { reduxDevToolsMiddleware } from "restato";

store.addMiddlewares(reduxDevTools(/* options */));

Redux DevTools expect action to have a type string. This middleware will use action function's name as type. If action doesn't have a name, /anonymous will be used. For async mutations, /async suffix will be appended to differentiate with synchronous mutations from the same action.

Redux DevTools Middleware

Bindings

To help with using restato, following bindings have been provided.

React

React binding is a thin wrapper around the store. It provides a useSelector hook for using in UI component.

// below requires a bundler that supports Subpath exports, see https://nodejs.org/api/packages.html#packages_subpath_exports
// for bundlers that don't support subpath exports, need to do
//
// import { reactStore } from "restato";
// const { dispatch, useSelector } = reactStore;
//
import { dispatch, useSelector } from "restato/react";

function Counter() {
  const count = useSelector((state) => state.count || 0);
  const increase = (state) => {
    const { count } = state;
    state.count = count ? count + 1 : 1;
  };
  return <button onClick={() => dispatch(increase)}>{count}</button>;
}

Above example uses the dispatch and useSelector from the default global store. You can also create a local store if the global one is not suitable.

// for bundlers don't support subpath exports, do
// import { createReactStore as createStore } from "restato";
import { createStore } from "restato/react";

const store = createStore();
...

Testing

A common requirement for writing tests for UI components is to initialise store to a certain state. That can be done easily by calling store.setState() to set store to the required state.

test("Counter", async (t) => {
  // initialise store to required state
  store.setState({ count: 0 });

  // then render component
  render(<Counter/>);

  // assertions
  expect(...);
});

Note

Frozen Map, Set, Date, etc

As mentioned above, object managed by store is frozen to prevent accidental mutating.

For values like normal object and array, this can be done easily with Object.freeze(); but is a different story for other type of object like instances of Map, Set, Date and potentially other types. Objects of these types are special as they are just a wrapper, real value is hidden inside. Object.freeze() only freezes the wrapper. Their internal state can still be accessed by using the methods available the type's prototype object.

const map = new Map();

// this only freezes the wrapper object
Object.freeze(map);

// internal state can still be manipulated
Map.prototype.set.call(map, "key", "value");

It seems monkey patching the prototype is the only way to work around this. But that may introduce more problem than it solves.

Because of this, the frozen version of these objects are special in that they are only look like an instance of those types. They have all the methods avialble on the type but can't be used as receiver to call functions from those prototypes.

function someAction(state) {
  state.map = new Map(Object.entries({ key: "value" }));

  // can be accessed like normal Map
  state.map instanceof Map; // true
  state.map.get("key") === "value";
  state.map.set("key", "new value");

  // will throw error
  Map.prototype.get.call(state.map);
  Map.prototype.set.call(state.map);
}

This should be fine in most of the cases and if these objects are used as receiver by some library to call the functions of those prototype, error will be thrown to let client know. We feel this is better than letting the object gets mutated silently. As that could introduce unexpected behaviour and is very hard to debug.