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>.
| Option | Type | Default | Description |
|---|---|---|---|
name | string | required | Slice name, used to prefix action types. |
initialState | S | required | State before any action runs. |
reducers | Record<string, (state: S, action: Action) => S> | required | Handlers keyed by action name; each becomes {name}/{key} and an entry in actions. |
extraReducers | Record<string, (state: S, action: Action) => S> | undefined | Handlers 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.
| Option | Type | Default | Description |
|---|---|---|---|
logger | Console | console | Sink; swap it in tests. |
filter | (action: *) => boolean | undefined | Return false to skip an action. |
bind(source, target, select, options)
Push a source’s value into a target whenever it changes.
Returns () => void.
| Option | Type | Default | Description |
|---|---|---|---|
equals | (a: *, b: *) => boolean | undefined | Skip the update when this returns true. Defaults to Object.is. |
immediate | boolean | true | Push 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.
| Option | Type | Default | Description |
|---|---|---|---|
equals | (a: *, b: *) => boolean | undefined | Change 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.
| Option | Type | Default | Description |
|---|---|---|---|
store | import('./store.js').Store<*> | required | Any object with getState and subscribe; dispatch is required only if descendants call useDispatch. |
children | import('react').ReactNode | required | Subtree 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 *.
| Option | Type | Default | Description |
|---|---|---|---|
store | import('../contract/bind.js').Source | undefined | Read this source instead of the provider’s — how you bind a query observer, or a second store, without another context. |
equals | (a: *, b: *) => boolean | undefined | Change test. Defaults to Object.is. |