Store

A Redux-shaped state container with no dependencies, no markup, and no tie to Corn UI.

import { ... } from '@corndev/store'

Install

npm install @corndev/store

The store contract is { getState, subscribe } — the same one Redux publishes and the same one React’s useSyncExternalStore consumes. So the hooks in @corndev/store-react also bind a real Redux store, which is what makes a partial migration possible, and bind drives any Corn UI controller from any of them.

Quick start — vanilla JS

No framework and no build step. This is the whole integration.

import { createStore, createSlice, applyMiddleware, thunk, bind } from '@corndev/store';

const todos = createSlice({
  name: 'todos',
  initialState: { items: [] },
  reducers: {
    added: (state, action) => ({ items: [...state.items, action.payload] }),
    cleared: () => ({ items: [] }),
  },
});

const store = createStore(todos.reducer, undefined, applyMiddleware(thunk()));

// Render it on a page with no framework. `bind` is what a hook is for React:
// the one call that connects state to the thing showing it. It pushes once
// immediately, then on every change, and returns its own teardown.
const count = document.querySelector('#count');
const unbind = bind(store, value => (count.textContent = value), state => state.items.length);

document.querySelector('#add')
  .addEventListener('click', () => store.dispatch(todos.actions.added('write it down')));

Why this exists

Redux is a good design carrying weight it no longer needs. @corndev/store keeps the parts that earned their place — a single state tree, plain-object actions, reducers, middleware, memoised selectors — in about 1.7 KB min+gzip with nothing underneath it.

It is not a Corn UI component. It ships no CSS and no markup, and it works on a page with no Corn UI on it at all. When Corn UI is there, bind connects the two without either side importing the other.

What it is not

There is no Immer, so handlers return new state rather than mutating a draft — state.items.push(x) is a bug here exactly as it is in a plain reducer. That is a deliberate trade: a state library that pulls a 15 KB dependency is not a replacement for the thing it replaces.

There is no createAsyncThunk and no loading-flag machinery. Server data belongs in Query, whose entire job is loading, error, staleness, deduplication and retries. A slice that owns isLoading, error and lastFetchedAt is re-implementing that badly.

Coming from Redux

The store contract is Redux’s own — getState, dispatch, subscribe, replaceReducer — so migration can be partial. The hooks in @corndev/store-react bind a real Redux store, because they are written against getState/subscribe rather than against createStore. You can adopt the hooks first and swap the store later, or never.

createSlice derives each action type from the slice name and the handler key, so the string constant that Redux codebases keep in a separate file — and misspell — has nowhere to go wrong.

Recipes

One state tree from several slices

import { createStore, combineReducers, createSlice } from '@corndev/store';

const auth = createSlice({
  name: 'auth',
  initialState: { user: null },
  reducers: {
    loggedIn: (state, action) => ({ user: action.payload }),
    loggedOut: () => ({ user: null }),
  },
});

const todos = createSlice({
  name: 'todos',
  initialState: { items: [] },
  reducers: {
    added: (state, action) => ({ items: [...state.items, action.payload] }),
  },
  // A slice may handle a type it does not own — this is how logging out
  // clears everything without auth knowing what else exists.
  extraReducers: { 'auth/loggedOut': () => ({ items: [] }) },
});

const store = createStore(combineReducers({ auth: auth.reducer, todos: todos.reducer }));

combineReducers returns the same root object by identity when nothing moved. That is load-bearing rather than an optimisation — it is what makes the reference check in bind and useSelector worth anything.

Derived data without recomputing on every dispatch

import { createSelector } from '@corndev/store';

const visibleTodos = createSelector(
  state => state.todos.items,
  state => state.todos.filter,
  (items, filter) => (filter === 'all' ? items : items.filter(t => t.done === (filter === 'done'))),
);

Without the memo, a selector that builds an array or object returns a fresh reference every call, so everything subscribed through it updates on every dispatch. visibleTodos.recomputations() makes that visible in a test.

Async work

import { applyMiddleware, thunk } from '@corndev/store';

const store = createStore(rootReducer, applyMiddleware(thunk({ api })));

const signIn = credentials => async (dispatch, getState, api) => {
  const user = await api.post('/session', credentials);
  dispatch(auth.actions.loggedIn(user));
  return user;
};

await store.dispatch(signIn({ email, password }));

The dispatch a thunk receives re-enters the whole middleware chain, so a thunk dispatching another thunk works.

Rendering, with no framework

const output = document.querySelector('#count');
const unbind = bind(store, value => (output.textContent = value), state => state.todos.items.length);

bind pushes once immediately, pushes again on every change, skips pushes when the selected value did not move, and returns its own teardown. Unbinding does not destroy the target — bind did not create it.

Rendering into a Corn UI component

import { createBadge } from '@corndev/ui';
import { bind } from '@corndev/store';

const badge = createBadge(document.querySelector('#count'));
bind(store, badge, state => ({ variant: state.todos.items.length ? 'primary' : 'default' }));

Every Corn UI create* controller exposes update(options), which is the whole of what bind needs on that side. Neither package imports the other.

Testing a slice

A slice is a pure function, so it needs no store to test:

const next = todos.reducer(todos.getInitialState(), todos.actions.added({ id: 1 }));
expect(next.items).toHaveLength(1);

With Corn UI

Neither package imports the other. bind needs getState/subscribe on one side and an update(options) method on the other, which is what every Corn UI create* controller returns.

// Drive a Corn UI component from the store. Neither package imports the other:
// `bind` only needs `getState`/`subscribe` on one side and `update()` on the other.
import { createBadge } from '@corndev/ui';
import { bind } from '@corndev/store';

const badge = createBadge(document.querySelector('#count'));
bind(store, badge, state => ({ variant: state.items.length ? 'primary' : 'default' }));

React

npm install @corndev/store @corndev/store-react
import { StoreProvider, useSelector, useDispatch } from '@corndev/store-react';

const App = () => (
  <StoreProvider store={store}>
    <TodoCount />
  </StoreProvider>
);

function TodoCount() {
  const count = useSelector(state => state.items.length);
  const dispatch = useDispatch();
  return <button onClick={() => dispatch(todos.actions.cleared())}>Clear {count}</button>;
}

API

createStore(reducer, preloadedState, enhancer)

Create a store.

Returns Store<S>.

combineReducers(reducers)

Compose a state object out of one reducer per key. Unchanged slices are returned by identity, so combineReducers produces the same root object when nothing changed — which is what makes useSelector’s reference equality check worth anything.

Returns Reducer<Record<string, any>>.

createSlice(options)

Define a slice of state together with the actions that change it. Each handler receives (state, action) and returns the next state — there is no draft object and no mutation tracking, so state.items.push(x) is a bug here exactly as it is in a plain reducer.

Returns Slice<S>.

OptionTypeDefaultDescription
namestringrequiredSlice name, used to prefix action types.
initialStateSrequiredState before any action runs.
reducersRecord<string, (state: S, action: Action) => S>requiredHandlers keyed by action name; each becomes {name}/{key} and an entry in actions.
extraReducersRecord<string, (state: S, action: Action) => S>undefinedHandlers for action types this slice does not own, keyed by the full type string.

createAction(type)

Build a standalone action creator. The returned function carries its own type, and stringifies to it, so it can be used directly as a key in extraReducers or a switch case without restating the string.

Returns ((payload?: *) => Action) & ActionCreator.

applyMiddleware(middlewares)

Wrap dispatch in a middleware chain. Middleware receives a dispatch that re-enters the whole chain, not the bare store one, so a thunk dispatching another thunk works. Dispatching while the chain is still being built throws rather than silently reaching a half-assembled dispatch.

Returns import('./store.js').StoreEnhancer<*>.

thunk(extra)

Let a dispatched function run instead of reaching the reducer. The function is called with (dispatch, getState, extra), and its return value is what dispatch returns — so an async thunk can be awaited.

Returns Middleware.

createLogger(options)

Log each action with the state either side of it.

Returns Middleware.

OptionTypeDefaultDescription
loggerConsoleconsoleSink; swap it in tests.
filter(action: *) => booleanundefinedReturn false to skip an action.

bind(source, target, select, options)

Push a source’s value into a target whenever it changes.

Returns () => void.

OptionTypeDefaultDescription
equals(a: *, b: *) => booleanundefinedSkip the update when this returns true. Defaults to Object.is.
immediatebooleantruePush the current value once at bind time.

derive(source, select, options)

Wrap a source in a narrower one that only notifies when its slice changes. The result satisfies the same contract as its input, so it can be handed to bind, to useSelector, or to another derive.

Returns Source.

OptionTypeDefaultDescription
equals(a: *, b: *) => booleanundefinedChange test. Defaults to Object.is.

createSelector(fns)

Build a selector that recomputes only when its inputs change.

Returns ((state: *, ...args: *) => *) & { recomputations: () => number, clear: () => void }.

React API — @corndev/store-react

StoreProvider(props)

Provide a store to the tree below.

Returns import('react').ReactElement.

OptionTypeDefaultDescription
storeimport('./store.js').Store<*>requiredAny object with getState and subscribe; dispatch is required only if descendants call useDispatch.
childrenimport('react').ReactNoderequiredSubtree that may read the store.

useStore()

The store from the nearest provider.

Returns import('./store.js').Store<*>.

useDispatch()

The store’s dispatch. Stable for the life of the store, so it is safe in a dependency array.

Returns (action: *) => *.

useSelector(select, options)

Subscribe to a slice of state and re-render when it changes.

Returns *.

OptionTypeDefaultDescription
storeimport('../contract/bind.js').SourceundefinedRead this source instead of the provider’s — how you bind a query observer, or a second store, without another context.
equals(a: *, b: *) => booleanundefinedChange test. Defaults to Object.is.