Skip to content
/ store Public

Redux reimagined with usability in mind and React hooks.

License

Notifications You must be signed in to change notification settings

rabelloo/store

Repository files navigation

Store ~ ci codecov

Redux reimagined with usability in mind and React hooks.

TODO: find a good name

Table of contents

Philosophy

  • All you need to create a store is your initial state.
  • Reducers are tied to types and execute directly on the action payload.
  • Reducers are also added to the store as they are needed, instead of during creation.
  • You can slice() your store and dispatch directly to it, changing only that part of state.

Restrictions (basically same as Redux):

  • All state must be serialisable, essentially JS primitives.
  • Reducers must be pure (don't mutate state) and return the new state.
  • Types must be unique - trying to register the same type again will throw an error.
  • (Recommended) store should be used as singleton (easy to do with ES modules, e.g. export store).

The advantages are:

  • Read and write are all O(1)
  • No boilerplate code (switch, action creators)
  • Granular control of state (with slice)
  • Modularity (register types and reducers in-place, as they are needed)

Improved from Redux:

  • Immutability and purity
    • TS types makes the entire state readonly
    • In mode: 'development' it is efficiently deeply frozen
  • Scalable
    • There's no innate overhead, performance is determined by your reducers individually
    • Subscriptions are notified only when their slice is affected (no diff or dirty checking)

Installation

TODO: find a name to publish on npm

npm i store

Usage

Vanilla

Also see examples/vanilla

import { createStore } from 'store';

const initialState = {
  count: 0,
};

// recommended usage as a singleton (one per app)
export const store = createStore(initialState);

console.log(store.state);
// { count: 0 }
// register reducers per type and get their dispatcher
const type = 'foo';
const reducer = (state, foo: boolean) => state;
const dispatchFoo = store.on(type, reducer);

dispatchFoo(true);
// same as `store.dispatch({ type, payload: true });`

console.log(store.state);
// { count: 0 }
// you can use `on()` directly on the root
const setCount = store.on(
  '[My Component] set count',
  // but you have to do boring state stitching/merging
  (state, count: number) => ({ ...state, count })
);

setCount(5);

console.log(store.state);
// { count: 5 }

// or you can `slice()` your state :D
const incrementCount = store
  .slice('count')
  .on('[My Component] increment count', (count, mod: number) => count + mod);

incrementCount(-5);

console.log(store.state);
// { count: 0 }
// you can also dispatch actions directly on the store or slices.
// There's no advantage in doing so, and not having the type registered
// will throw an error if your `mode` is "development".
store.dispatch({ type: 'my action', payload: { optional: true } });

See Config for more.

// subscribe to changes
store.subscribe((state) => console.log('root state:', state));
store.slice('count').subscribe((count) => console.log('count:', count));
store.slice('foo').subscribe((foo) => console.log('foo:', foo));

store.dispatch(/* action that changes `state.count` */);
// root state: { count: 1, foo: false }
// count: 1

store.dispatch(/* action that changes `state.foo` */);
// root state: { count: 1, foo: true }
// foo: true

store.dispatch(/* action that changes `state` */);
// root state: { count: 0, foo: false }
// count: 0
// foo: false

Also see examples/vanilla

React

Also see examples/react

import { createStore, createStoreContext } from 'store';

const initialState = {
  foo: 'bar',
};

// create store like vanilla
const store = createStore(initialState);

// but then export the Provider and hook
export const { StoreProvider, useStore } = createStoreContext(store);
// provide your store context to your app
export const Root = () => (
  <StoreProvider>
    <App />
  </StoreProvider>
);
export function RootState() {
  // entire root state
  const [state] = useStore();

  return <pre>{JSON.stringify(state, null, 2)}</pre>;
}
export function Foo() {
  // select from state
  const [foo] = useStore((state) => state.foo);

  return <div>{foo}</div>;
}
export function RootStateReset() {
  // register a type and reducer
  const [state, setState] = useStore({
    type: '[FooInput] set foo',
    reduce: (_state, newState: State) => newState,
  });

  return <button onClick={() => setFoo({ foo: 'bar' })}>Reset state</button>;
}
export function FooInput() {
  // slice, then register a type and reducer
  const [foo, setFoo] = useStore({
    slice: ['foo'],
    type: '[FooInput] set foo',
    reduce: (_, foo: string) => foo,
  });

  return (
    <input
      defaultValue={foo}
      onInput={(ev) => setFoo(ev.currentTarget.value)}
    />
  );
}
// TypeScript's tuple inference is still not perfect,
// so if your editor is not suggesting your state's keys correctly
// you can use the function `my().at()` to help create the slice array.
import { my } from 'store';

const initialState = { foo: { bar: 'foo.bar' } };
type State = typeof initialState;

// use your State interface/type
const { at } = my<State>();

const [fooBar, setFooBar] = useStore({
  slice: at('foo', 'bar'),
  // same as ['foo', 'bar']
  // but now with TypeScript suggestions as you type
});

Also see examples/react

Config

const initialState = {};

// config is second argument
const store = createStore(initialState, {
  mode: 'development' || 'test' || 'production',
  middleware: [myMiddleware, anotherMiddleware],
});

// when not specified, the default is:
const defaultConfig = {
  mode: process.env.NODE_ENV || 'production',
  middleware: mode === 'development' ? [logger(), freeze()] : [],
};

// each config field has to be overriden individually.
// e.g. this will still have the default middleware applied
const store = createStore(initialState, {
  mode: 'development',
});

Mode

Running in development has one major difference:

When an unregistered type of action is dispatched, an Error will be thrown.

This helps find bugs earlier in the development cycle, as types are untyped (TypeScript wise), and you might not catch typos or other errors.

// e.g.
const sendMyAction = store.on('my action', (state) => state);

// will never throw as the `type` is captured in the closure
sendMyAction();

// not recommended, but here's where the Error being thrown helps
store.dispatch({ type: 'my actio' });
// Error('No registered reducer for "my actio"')

It is recommended you set the environment variable NODE_ENV depending on where you're running code.

That way the config's mode and middleware will be applied correctly per environment, as suggested in Config.

Still, if you have a different way to determine your environment, feel free to pass a variable to your config.

const store = createStore(initialState, {
  mode: isProduction ? 'production' : 'development',
});

Middleware

Middleware are functions that run on every dispatch just after the reducer, but before setting the state.

They execute in the order in which they are provided on createStore().

There are some middleware provided, those with an * are applied by default when mode === 'development':

  • *Freeze - Deeply freezes every action's payload dispatched to store.
    • Useful to ensure state is immutable.
    • Not recommended in production because performance.
  • *Logger - Logs all middleware arguments on every action dispatch to store.
    • Useful to track changes and debug state.
    • Not recommended in production because it exposes internal state to console.
  • Persist - Persists state to localStorage and hydrates from it on init.
    • Useful when using Hot Module Reload or Fast Refresh.
    • Useful when you want the entire state to persist across sessions.

You can also create your own middleware to do what you will - e.g. logging to an external tool like DataDog.

export function myMiddleware<State>(): Middleware<State> {
  return ({ action, from, nextState, state }) => {
    DataDog.send({ action, from, nextState, state });

    // don't forget to return `nextState` or you might be left wondering
    // why your state is suddenly `undefined`.
    return nextState;
  };
}

action is e.g. { type, payload }

from is:

  • a string or symbol that represents the slice path.
  • an unique key that might not be deterministic. Try not to rely on its format.
  • e.g. root is Symbol('@store root')
  • e.g. slice('foo', 'bar') is 'foo.bar'

nextState is the output from the reducer, or from the previous middleware.

state is the current state of the slice or store.

Async

You might be familiar with the concept of thunk middleware, or sagas, or asynchronous action dispatchers.

Due to how this library has been designed there should be no need for those, see:

const setFoo = store.on('[foo] success / set', (_, foo: string) => foo);
const asyncSetFoo = store.on('[foo] async set', (state) => {
  fetch('my/api').then(setFoo);
  return state;
});

asyncSetFoo();

About

Redux reimagined with usability in mind and React hooks.

Resources

License

Stars

Watchers

Forks

Releases

No releases published