@corndev/store
v1.0.0-beta.4
Published
Redux-shaped state container with no dependencies — usable with or without Corn UI
Maintainers
Readme
@corndev/store
A Redux-shaped state container in ~1.7 KB min+gzip, with zero dependencies.
It is not a Corn UI component. It ships no CSS and no markup, it does not import
@corndev/ui, and it runs 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.
npm install @corndev/storeSixty seconds
import { createStore, createSlice, bind } from '@corndev/store';
const counter = createSlice({
name: 'counter',
initialState: { n: 0 },
reducers: {
added: (state, action) => ({ n: state.n + (action.payload ?? 1) }),
reset: () => ({ n: 0 }),
},
});
const store = createStore(counter.reducer);
// Render it. `bind` is what a hook is on a React page: the one call that
// connects state to the thing showing it.
const output = document.querySelector('#count');
bind(store, value => (output.textContent = value), state => state.n);
document.querySelector('#add')
.addEventListener('click', () => store.dispatch(counter.actions.added()));That is the whole integration. No build step, no framework, no provider.
The store
createStore(reducer, preloadedState?, enhancer?) returns Redux's own contract:
| Method | What it does |
|---|---|
| getState() | The current state. Never mutate the result. |
| dispatch(action) | Runs the action through the reducer, then notifies subscribers. Returns the action. |
| subscribe(listener) | Registers a listener; returns its unsubscribe function. |
| replaceReducer(next) | Swaps the reducer, for code splitting. |
Two rules the store enforces rather than documents:
- Reducers must not dispatch. Re-entering
dispatchfrom inside a reducer throws instead of corrupting the state it is midway through computing. - Actions are plain objects with a string
type. Dispatching anything else throws — except whenthunkis installed, which is the point ofthunk.
A store dispatches INIT on creation so reducers can supply their defaults, and
REPLACE after replaceReducer so a swapped-in reducer can fill its slice.
Preloaded state
const store = createStore(rootReducer, window.__STATE__);Useful for server-rendered HTML, or for restoring from localStorage.
Slices
createSlice derives each action type from the slice name and the handler key,
so the action-type constant — the thing Redux codebases keep in a separate file
and then misspell — has nowhere to go wrong.
const todos = createSlice({
name: 'todos',
initialState: { items: [], filter: 'all' },
reducers: {
added: (state, action) => ({ ...state, items: [...state.items, action.payload] }),
removed: (state, action) => ({ ...state, items: state.items.filter(t => t.id !== action.payload) }),
filtered:(state, action) => ({ ...state, filter: action.payload }),
},
// Types this slice does not own, keyed by the full string.
extraReducers: {
'auth/loggedOut': () => ({ items: [], filter: 'all' }),
},
});
todos.actions.added.type; // 'todos/added'
`${todos.actions.added}`; // 'todos/added' — usable as an object key
todos.actions.added({ id: 1 }); // { type: 'todos/added', payload: { id: 1 } }
todos.getInitialState(); // { items: [], filter: 'all' }There is no Immer. Handlers return new state; they do not mutate a draft.
state.items.push(x)is a bug here exactly as it is in a plain reducer. That is deliberate — a state library that pulls a 15 KB dependency is not a replacement for the thing it replaces.
Composing slices
import { combineReducers } from '@corndev/store';
const store = createStore(combineReducers({
todos: todos.reducer,
auth: auth.reducer,
}));
store.getState(); // { todos: { … }, auth: { … } }combineReducers returns the same root object by identity when no slice
moved. That is load-bearing rather than an optimisation: it is what makes the
reference check in bind and useSelector worth anything.
Middleware
import { createStore, applyMiddleware, thunk, createLogger } from '@corndev/store';
const store = createStore(
rootReducer,
applyMiddleware(thunk({ api }), createLogger({ filter: a => a.type !== 'ticked' })),
);Middleware runs left to right. applyMiddleware can be passed as the second
argument when there is no preloaded state, exactly as in Redux.
Thunks
A dispatched function runs instead of reaching the reducer, and receives
(dispatch, getState, extra) — where extra is whatever you passed to
thunk(...). Whatever the thunk returns is what dispatch returns, so an async
thunk can be awaited.
const loadTodos = () => async (dispatch, getState, api) => {
dispatch(todos.actions.loading());
try {
dispatch(todos.actions.loaded(await api.get('/todos')));
} catch (error) {
dispatch(todos.actions.failed(error.message));
}
};
await store.dispatch(loadTodos());The dispatch a thunk receives re-enters the whole middleware chain, so a
thunk dispatching another thunk works.
For server data specifically, reach for
@corndev/queryinstead of hand-rolling loading flags in a slice. Loading, error, staleness, deduplication and retries are its whole job.
Writing your own
const crashReporter = ({ getState }) => next => action => {
try {
return next(action);
} catch (error) {
report(error, { action, state: getState() });
throw error;
}
};Selectors
createSelector(...inputs, combiner) recomputes only when an input changes.
Without it, any selector that builds an object returns a fresh reference every
call, and everything subscribed through it updates 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'))),
);
visibleTodos(store.getState());
visibleTodos.recomputations(); // for tests
visibleTodos.clear(); // drop the memoInputs may also be passed as an array, and extra arguments are forwarded to
every input — createSelector((state, id) => state.byId[id], todo => todo.title).
Connecting it to the DOM
bind(source, target, select?, options?)
const unbind = bind(store, target, select, { equals, immediate });- source — anything with
{ getState, subscribe }. This store, a@corndev/queryobserver, or a real Redux store. - target — a function, or anything with an
update(options)method, which is what every Corn UIcreate*controller returns. - select — narrows the state. Defaults to identity.
equals— skip the push when this returns true. Defaults toObject.is.immediate— push the current value once at bind time. Defaults totrue.
It returns its unbind. Unbinding does not destroy the target — bind did not
create it.
// A plain callback
bind(store, todos => renderList(todos), state => state.todos.items);
// A Corn UI controller — no import of @corndev/ui here, and no import of
// @corndev/store there. `update()` is the entire contract.
import { createBadge } from '@corndev/ui';
const badge = createBadge(document.querySelector('#count'));
bind(store, badge, state => ({ variant: state.todos.items.length ? 'primary' : 'default' }));derive(source, select, options?)
Wraps a source in a narrower one that only notifies when its slice changes. The
result satisfies the same contract, so it composes with bind, with
useSelector, or with another derive.
const openCount = derive(store, state => state.todos.items.filter(t => !t.done).length);
openCount.getState(); // 3
bind(openCount, n => (title.textContent = `${n} open`));React
npm install @corndev/store @corndev/store-reactSee @corndev/store-react.
The hooks are written against { getState, subscribe } rather than against
createStore, so they bind a real Redux store too — which is what makes a
partial migration possible.
Coming from Redux
| Redux / Redux Toolkit | Here |
|---|---|
| createStore, configureStore | createStore(reducer, preloadedState?, enhancer?) |
| combineReducers | combineReducers |
| createSlice | createSlice — but handlers return state, they do not mutate a draft |
| createAction | createAction |
| applyMiddleware | applyMiddleware |
| redux-thunk | thunk(extra) |
| redux-logger | createLogger({ logger, filter }) |
| reselect's createSelector | createSelector |
| react-redux's Provider / hooks | @corndev/store-react |
| createAsyncThunk | not included — use @corndev/query for server state |
| Immer drafts | not included — return new state |
| DevTools extension | not included — createLogger, or subscribe and log |
| bindActionCreators | not included — dispatch(actions.x()) is short enough |
Because the store contract is Redux's own, migration can be partial in either
direction: point @corndev/store-react's hooks at your existing Redux store
first, and swap the store itself later, or never.
Types
.d.ts files are generated from the source JSDoc at build time, so the published
types cannot drift from the documentation. No @types package is needed.
Licence
MIT © Corndog Development LLC
