react-state-selector

npm install react-state-selector

Usage no npm install needed!

<script type="module">
  import reactStateSelector from 'https://cdn.skypack.dev/react-state-selector';
</script>

README

React State Selector

logo

codecov npm version bundlephobia license combined statuses

React global state management, the performant, type safe and easy way

npm install react-state-selector
# or
yarn add react-state-selector

What if we combine immer, reselect and even React Context API to make a performant and expressive React global state manager?

Check https://pabloszx.github.io/react-state-selector** for more detailed examples and use cases.

Table of Contents

Features

  • Redux DevTools support
  • async actions (redux-thunk alike)
  • TypeScript first class support
  • reselect createSelector support
  • Easy and efficient localStorage data persistence
  • Support for AsyncStorage data persistence (for example, for React Native AsyncStorage), just add it inside the persistenceMethod option in createStore or createStoreContext, and use the helpers isReady (createStore) or useIsReady (createStoreContext) to wait for them to be ready before it's usage.

Basic Usage

For simple global stores you can use createStore.

import { createStore } from "react-state-selector";

const {
  hooks: { useCountA, useCountB },
  actions: { incrementA, incrementB },
} = createStore(
  {
    countA: 0,
    countB: 0,
  },
  {
    hooks: {
      useCountA: ({ countA }) => {
        // Here reselect will automatically memoize this selector
        // and only rerender the component when absolutely needed
        return countA;
      },
      useCountB: ({ countB }) => {
        return countB;
      },
    },
    actions: {
      incrementA: (n: number) => (draft) => {
        // Here you can mutate "draft", and immer will
        // automatically make the immutable equivalent
        draft.countA += n;
      },
      incrementB: (n: number) => (draft) => {
        draft.countB += n;
      },
    },
  }
);

// ...

const CounterA = () => {
  const a = useCountA();

  return (
    <div>
      <h1>Counter A</h1>
      <span>{a}</span>
      <button onClick={() => incrementA(1)}>+</button>
    </div>
  );
};

const CounterB = () => {
  const b = useCountB();

  return (
    <div>
      <h1>Counter B</h1>
      <span>{b}</span>
      <button onClick={() => incrementB(2)}>+</button>
    </div>
  );
};

This library uses type inference to automatically help you with auto-completion and type-safety, even if you only use JavaScript and not TypeScript!.

Basic Context Usage

If you need multiple instances of a specific store you can use the React Context API to make specific instances of the store.

import { createStoreContext } from "react-state-selector";

const {
  hooks: { useCountA, useCountB },
  useActions,
  Provider,
} = createStoreContext(
  {
    countA: 0,
    countB: 0,
  },
  {
    hooks: {
      useCountA: ({ countA }) => {
        // Here reselect will automatically memoize this selector
        // and only rerender the component when absolutely needed
        return countA;
      },
      useCountB: ({ countB }) => {
        return countB;
      },
    },
    actions: {
      incrementA: (n: number) => (draft) => {
        // Here you can mutate "draft", and immer will
        // automatically make the immutable equivalent
        draft.countA += n;
      },
      incrementB: (n: number) => (draft) => {
        draft.countB += n;
      },
    },
  }
);

// ...

const CounterA = () => {
  const a = useCountA();
  const { incrementA } = useActions();

  return (
    <div>
      <h1>Counter A</h1>
      <span>{a}</span>
      <button onClick={() => incrementA(1)}>+</button>
    </div>
  );
};

const CounterB = () => {
  const b = useCountB();
  const { incrementB } = useActions();
  return (
    <div>
      <h1>Counter B</h1>
      <span>{b}</span>
      <button onClick={() => incrementB(2)}>+</button>
    </div>
  );
};

// ...

const Counters = () => {
  return (
    <>
      <Provider>
        <CounterA />
        <CounterB />
      </Provider>
      <Provider>
        <CounterB />
      </Provider>
    </>
  );
};

Default API

By default every created store gives a couple of out of the box functionality, if you don't use them it's okay, but they could end up being handy:

createStore

produce: function(draft => void | TStore): TStore

  • Synchronous change to the store state, you should give it a function that will mutate the state and it will give the resulting global state after the transformation. Don't worry about mutating the draft, immer will do the transformations. At first it might feel weird if you are used to making the immutable equivalent of every mutation and using (abusing) the spread syntax.
  • If you return something in the draft function, it will transform the entire global state into that value. Read more.
  • If you don't give it a function, it will work simply as a state getter, so you can check the global state anytime without any restriction.
const state = produce((draft) => {
  draft.a += 1;
});
console.log(produce() === state); // true

asyncProduce: function(async draft => void | TStore): Promise TStore

  • Asynchronous change to the store state, you should give it an async function that will mutate the state and it will give a promise of the resulting global state after the transformation.
  • It is often better to use custom actions for dealing with asynchronous requests, since here when you start the async function, you might had received a stale draft state after the request is done.
  • You shouldn't rely on this feature to transform the entire state as with produce or custom actions;
const state = await asyncProduce(async (draft) => {
  draft.users = await (await fetch("/api/users")).json();
});
console.log(produce() === state); // true

useStore: function(): TStore

  • Hook that subscribes to any change in the store
const CompStore = () => {
  const store = useStore();

  return <div>{JSON.stringify(store, null, 2)}</div>;
};

createStoreContext

useStore: function(): TStore

  • Hook that subscribes to any change in the store
const CompStore = () => {
  const store = useStore();

  return <div>{JSON.stringify(store, null, 2)}</div>;
};

useProduce: function(): { produce, asyncProduce }

const IncrementComp = () => {
  const { produce } = useProduce();

  return (
    <button
      onClick={() =>
        produce((draft) => {
          draft.count += 1;
        })
      }
    >
      Increment
    </button>
  );
};

Custom API

This is where this library aims to work the best using type inference, memoization and mutability with immutability seemlessly without any boilerplate needed.

Custom Hooks

In both createStore and createStoreContext the functionality is the same.

You should specify an object inside the options object (second parameter) called hooks.

Inside this object you have to follow the custom hooks naming rule for every custom hook, and inside, you give a function that will receive two parameters, the first one will be the state of the store, and the second one will be the optional custom props of the hook.

In the resulting store object you will get an object field called hooks, which will have all the custom hooks specified in the creation.

// const ABStore = createStoreContext(
const ABStore = createStore(
  { a: 1, b: 2 },
  {
    hooks: {
      useA: ({ a, b }) => {
        return a;
      },
      useB: ({ a, b }) => {
        return b;
      },
      useMultiplyAxN: (
        { a, b },
        n: number
        /* Only if you are using TypeScript 
      you have to specify the type of the props */
      ) => {
        return a * n;
      },
    },
  }
);

const { useA, useB } = ABStore.hooks;
// You can destructure the hooks if you want

const A = () => {
  const a = useA();

  return <p>{a}</p>;
};
const B = () => {
  const b = useB();

  return <p>{b}</p>;
};
const AxN = () => {
  // Or you can just call the hook from
  // the store object itself
  const axn = ABStore.hooks.useMultiplyAxN(10);

  return <p>{axn}</p>;
};

Check https://pabloszx.github.io/react-state-selector** for more advanced usage, like giving multiple props to a custom hook or returning a new instance of data based on the state and props, all of those, efficiently.

Custom Actions

A very important feature of any global state is being able to modify it based on arguments given to a function and/or based on the current state, and using a reducer and dispatching action types and payload is a possible solution, but in this library the proposed solution is to specify the action types explicitly in the function names and it's payload in it's arguments.

In both createStore and createStoreContext the functionality is the same, but the usage in createStoreContext is a bit different due to React Context API constraints.

You should specify an object inside the options object (second parameter) called actions and/or asyncActions.

Actions

Inside the actions object you have to give functions called whatever you want, which will receive the custom arguments of the action, and this function should return another function which will receive the state draft, and that one should either return nothing or a new instance of the store state, just like produce.

The resulting object store will have either:

  • actions object field in createStore.
  • useActions hook that returns the custom actions in createStoreContext
const Store = createStore(
  { a: 1 },
  {
    actions: {
      increment: (n: number) => (draft) => {
        draft.a += n;
      },
    },
  }
);
const StoreCtx = createStore(
  { b: 1 },
  {
    actions: {
      decrement: (n: number) => (draft) => {
        draft.b -= n;
      },
    },
  }
);
const A = () => {
  const { a } = Store.useStore();

  return (
    <div>
      <button onClick={() => Store.increment(5)}>Increment</button>
      <p>{a}</p>
    </div>
  );
};
const B = () => {
  const { b } = StoreCtx.useStore();
  const { decrement } = StoreCtx.useActions();

  return (
    <div>
      <button onClick={() => decrement(5)}>Decrement</button>
      <p>{b}</p>
    </div>
  );
};

Async Actions

Async actions need to be defined in another object inside the options object called asyncActions, and the first function should not be async itself since it receives a dispatch like function which works just like produce, and after that you should define the async function which will receive the parameters you expect in the action.

The async actions are put separately in an asyncActions object or useAsyncActions() hook.

enum State {
  waiting,
  loading,
  complete,
  error,
}

// const Store = createStoreContext(
const Store = createStore(
  {
    data: undefined,
    state: State.waiting,
  },
  {
    asyncActions: {
      getData: (produce) => async (bodyArgs) => {
        produce((draft) => {
          draft.state = State.loading;
        });

        try {
          const response = await axios.post("/data", bodyArgs);

          produce((draft) => {
            draft.state = State.complete;
            draft.data = response.data;
          });
        } catch (err) {
          console.error(err);
          produce((draft) => {
            draft.state = State.error;
          });
        }
      },
    },
  }
);

const Data = () => {
  const storeData = Store.useStore();

  // const {getData} = Store.useAsyncActions();
  const { getData } = Store.asyncActions;

  switch (storeData.state) {
    case State.loading:
      return <p>Loading...</p>;
    case State.complete:
      return <p>{JSON.stringify(storeData.data)}</p>;
    case State.waiting:
      return (
        <button
          onClick={async () => {
            const newStore = await getData();
            console.log("newStore", newStore);
          }}
        >
          Get Data
        </button>
      );
    case State.error:
    default:
      return <p>ERROR! Check the console</p>;
  }
};

Keep in mind that you can mix common synchronous actions and async actions in a single store, but you should not repeat the action names in both objects.

localStorage data persistence and DevTools

When creating an store via createStore or createStoreContext you can specify some fields that enable some useful features:

//createStoreContext(
createStore(
  {
    foo: "bar",
  },
  {
    /**
     * devName
     *
     * Activates the Redux DevTools for this store using
     * this name as reference.
     */
    devName: "fooBarStore",

    /**
     * devToolsInProduction
     *
     * Activates the Redux Devtools functionality in production.
     *
     * By default this is false
     */
    devToolsInProduction: true,
    storagePersistence: {
      /**
       * isActive
       *
       * Activates the data persistence in this store
       **/
      isActive: true,
      /**
       * persistenceKey
       *
       * Set the key used for the storage persistence method.
       *
       * It has to be a string, and if it's not specified
       * reuses the "devName", but it has to exists at least one
       * of these two if the storagePersistence is active
       **/
      persistenceKey: "fooBarStore",
      /**
       * debounceWait
       *
       * Calling an external store like localStorage every time
       * any change is made to the store is computationally expensive,
       * and that's why by default this functionality is being debounced
       * to be called only when needed, after X amount of milliseconds
       * since the last change to the store.
       *
       * By default it's set to 3000 ms, but you can customize it to
       * be 0 if you want almost instant save to the persistence store
       **/
      debounceWait: 1000,
      /**
       * persistenceMethod
       *
       * You also can customize the persistence method,
       * but by default uses window.localStorage.
       *
       * Keep in mind that it should follow the same API
       * of setItem and getItem of localStorage
       **/
      persistenceMethod: window.localStorage,
      /**
       * isSSR
       *
       * Flag used to specify that this store is going to be
       * used in server side rendering environments and it prevents
       * client/server mismatched html on client side hydration
       *
       * false by default
       **/
      isSSR: true,
    },
  }
);

Map, Set, old browsers, React Native support

In Immer latest version in order to reduce bundle size if you need support for Map, Set, old browsers or React Native you need to call some specific Immer functions as early as possible in your application.

// You can import from either immer or react-state-selector

// import { enableES5, enableMapSet } from "immer";
import { enableES5, enableMapSet } from "react-state-selector";

// Import and call as needed

enableES5();

enableMapSet();

Immer documentation


Heavily inspired by redux and react-sweet-state