@radio-garden/rematch
v1.1.0
Published
A lightweight, type-safe state management library built on Redux. Inspired by [Rematch](https://rematchjs.org/), with a simplified API and enhanced TypeScript support.
Readme
@radio-garden/rematch
A lightweight, type-safe state management library built on Redux. Inspired by Rematch, with a simplified API and enhanced TypeScript support.
Differences from Rematch
- No plugins - Selectors and other features are built-in, not separate packages
store.selectorandstore.select- Two ways to access selectors: with state argument (foruseSelector) or bound to current state- Auto-generated property selectors -
selector.model.propertyNameis automatically created for each state property store.watchSelector- Built-in reactive state watching, also available inside effectsstore.createSelector- Built-in memoized selector creation using reselect- Selectors defined on models - Define selectors directly in the model, with access to
createSelectorand cross-model selectors - Effects receive more utilities - Effects factory receives
{ dispatch, getState, select, selector, watchSelector } onStorehook - Set up reactive side effects when the store is ready
Installation
npm install @radio-garden/rematch redux reselect
# or
pnpm add @radio-garden/rematch redux reselectQuick Start
import { createModel, createRematchStore, type Models } from '@radio-garden/rematch';
// Define your models interface
interface AppModels extends Models<AppModels> {
counter: typeof counter;
}
// Create a model
const counter = createModel<{ count: number }, AppModels>()({
state: { count: 0 },
reducers: {
increment(state) {
return { count: state.count + 1 };
},
add(state, payload: number) {
return { count: state.count + payload };
}
},
effects: ({ dispatch }) => ({
async incrementAsync() {
await new Promise(resolve => setTimeout(resolve, 1000));
dispatch.counter.increment();
}
}),
selectors: ({ selector }) => ({
doubled: state => selector.counter.count(state) * 2
})
});
// Create the store
const store = createRematchStore<AppModels>({
name: 'MyApp',
models: { counter }
});
// Use it
store.dispatch.counter.increment();
store.dispatch.counter.add(5);
await store.dispatch.counter.incrementAsync();
console.log(store.getState().counter.count); // 7
console.log(store.select.counter.doubled()); // 14API
createRematchStore<TModels>(config)
Creates a new store instance.
const store = createRematchStore<AppModels>({
name: 'MyApp', // Optional store name
models: { counter, user }, // Your models
redux: { // Optional Redux configuration
middlewares: [], // Custom Redux middlewares
devtoolOptions: { disabled: false },
devtoolCompose: composeWithDevTools // For React Native
}
});createModel<TState, TModels>()(modelConfig)
Creates a typed model definition.
const user = createModel<{ name: string; age: number }, AppModels>()({
state: { name: '', age: 0 },
reducers: {
setName(state, name: string) {
return { ...state, name };
},
setAge(state, age: number) {
return { ...state, age };
}
},
effects: store => ({
async fetchUser(userId: string) {
const user = await api.getUser(userId);
this.setName(user.name);
this.setAge(user.age);
}
}),
selectors: ({ selector, createSelector }) => ({
// Simple selector
isAdult: state => selector.user.age(state) >= 18,
// Memoized selector using createSelector
fullInfo: createSelector(create =>
create(
[selector.user.name, selector.user.age],
(name, age) => `${name} (${age} years old)`
)
)
})
});Selectors can be defined as a plain object or a factory function:
// Plain object (when you don't need cross-model selectors)
selectors: {
doubled(state): number {
return state.counter.count * 2;
}
}
// Factory function (when you need access to other selectors or createSelector)
selectors: ({ selector, createSelector }) => ({
doubled(state): number {
return selector.counter.count(state) * 2;
}
})The factory function receives:
selector- Access selectors from any modelcreateSelector- Create memoized selectors using reselectgetState- Get current root state (useful for deriving types)
Important: Always add explicit return type annotations to selectors to avoid recursive type definitions:
selectors: ({ selector }) => ({
// Explicit return types prevent TypeScript circular reference errors
tabState(state): TabState | undefined {
const { tab, tabs } = state.browser;
return tab ? tabs[tab] : undefined;
},
currentPage(state): Page | undefined {
return selector.browser.tabState(state)?.currentPage;
}
})For complex memoized selectors, use a type helper to ensure proper typing:
// Define a type helper for selectors
type CreateSelector<TRootState, TReturn> = (state: TRootState) => TReturn;
const browser = createModel<BrowserState, AppModels>()({
state: { /* ... */ },
selectors: ({ selector, createSelector, getState }) => {
// Derive RootState type from getState
type RootState = ReturnType<typeof getState>;
// Use the type helper for complex return types
const pages: CreateSelector<RootState, {
currentPage: Page;
previousPage: Page | undefined;
}> = createSelector(create =>
create(
[selector.browser.history, selector.browser.pageCache],
(history, pageCache) => ({
currentPage: pageCache[history.current],
previousPage: history.previous ? pageCache[history.previous] : undefined
})
)
);
return { pages };
}
});Store Methods
store.dispatch
Dispatch reducers and effects:
store.dispatch.counter.increment(); // No payload
store.dispatch.counter.add(5); // With payload
store.dispatch.counter.addWithMeta(5, { multiplier: 2 }); // With meta
await store.dispatch.user.fetchUser('123'); // Async effectstore.selector
Get selectors that require state as an argument. Useful for React hooks like useSelector or when you need to work with a specific state snapshot.
const state = store.getState();
// Get entire model state
store.selector.counter(state); // { count: 5 }
store.selector.user(state); // { name: 'John', age: 25 }
// Auto-generated property selectors
store.selector.counter.count(state); // 5
store.selector.user.name(state); // 'John'
// Custom selectors defined in the model
store.selector.counter.doubled(state); // 10
store.selector.user.isAdult(state); // trueUse with React Redux:
import { useSelector } from 'react-redux';
function Counter() {
const count = useSelector(store.selector.counter.count);
const doubled = useSelector(store.selector.counter.doubled);
return <div>{count} (doubled: {doubled})</div>;
}store.select
Get bound selectors that automatically use current state. Unlike store.selector, these don't require passing state as an argument.
// Get entire model state
store.select.counter(); // { count: 5 }
store.select.user(); // { name: 'John', age: 25 }
// Auto-generated property selectors
store.select.counter.count(); // 5
store.select.user.name(); // 'John'
store.select.user.age(); // 25
// Custom selectors defined in the model
store.select.counter.doubled(); // 10
store.select.user.isAdult(); // trueThis is useful when you need quick access to the current state without managing state references:
// Inside an effect or anywhere with store access
if (store.select.user.isAdult()) {
console.log(`User ${store.select.user.name()} is an adult`);
}store.createSelector
Create memoized selectors using reselect:
const selectTotal = store.createSelector(create =>
create(
[state => state.counter.count, state => state.user.age],
(count, age) => count + age
)
);
selectTotal(store.getState()); // Memoized resultstore.watchSelector
Watch for state changes:
// Watch a single selector
const unsubscribe = store.watchSelector(
state => state.counter.count,
(newValue, oldValue) => {
console.log(`Count changed from ${oldValue} to ${newValue}`);
}
);
// Watch multiple selectors
store.watchSelector(
[state => state.counter.count, state => state.user.age],
([newCount, newAge], [oldCount, oldAge]) => {
console.log('Values changed');
}
);
// Trigger immediately with current value
store.watchSelector(
state => state.counter.count,
(value) => console.log('Current count:', value),
{ initial: true }
);
// Stop watching
unsubscribe();store.subscribe
Low-level Redux subscription:
const unsubscribe = store.subscribe(() => {
console.log('State changed:', store.getState());
});store.getState
Get the current state:
const state = store.getState();
// { counter: { count: 0 }, user: { name: '', age: 0 } }React Native Integration
For React Native with Flipper or Reactotron, pass a custom devtoolCompose:
import { composeWithDevTools } from '@redux-devtools/extension';
const store = createRematchStore<AppModels>({
models: { counter },
redux: {
devtoolCompose: composeWithDevTools
}
});To disable devtools:
const store = createRematchStore<AppModels>({
models: { counter },
redux: {
devtoolOptions: { disabled: true }
}
});Effects
The effects factory receives the store object with these properties:
dispatch- Dispatch actions to any modelgetState- Get current root stateselect- Bound selectors (no state argument needed)selector- Selectors requiring state argumentwatchSelector- Watch for state changes
You can use the store directly or destructure what you need:
// Using store directly
effects: store => ({
async syncAll() {
await this.fetchData();
store.dispatch.otherModel.refresh();
}
})
// Destructuring
effects: ({ dispatch, select, watchSelector, selector }) => ({
async initialize() {
// Use select for quick state access
if (select.user.isAdult()) {
dispatch.features.enableAdultContent();
}
// Watch for state changes within effects
watchSelector(
[selector.user.name, selector.settings.theme],
([name, theme]) => {
console.log('User or theme changed');
},
{ initial: true }
);
}
})Effect Parameters
Each effect receives the payload as first argument and rootState as second:
effects: ({ dispatch }) => ({
async updateIfNeeded(threshold: number, rootState) {
if (rootState.counter.count > threshold) {
dispatch.counter.reset();
}
}
})Effects with this Context
Inside effects, this is bound to the model's dispatch object:
effects: {
async saveUser(userData: UserData) {
const saved = await api.saveUser(userData);
this.setName(saved.name); // Calls user.setName reducer
this.setAge(saved.age); // Calls user.setAge reducer
}
}onStore Hook
The onStore hook is called after the store is fully created. Use it to set up reactive side effects that respond to state changes.
This approach avoids circular dependency issues that would occur if you tried to import the store directly into your model file - the store is passed to you instead.
const counter = createModel<{ count: number }, AppModels>()({
state: { count: 0 },
reducers: {
increment: state => ({ count: state.count + 1 }),
},
onStore: ({ watchSelector, selector }) => {
// React to count changes
watchSelector(selector.counter.count, (newCount, oldCount) => {
console.log(`Count changed: ${oldCount} → ${newCount}`);
syncToServer(newCount);
});
}
});The onStore callback receives the full store object, which you can destructure to get only what you need:
// Full store access
onStore: store => {
store.watchSelector(/* ... */);
}
// Destructured
onStore: ({ watchSelector, selector, dispatch, select, getState }) => {
// Set up watchers, dispatch initial actions, etc.
}Common Use Cases
Syncing state to external services:
onStore: ({ watchSelector, selector }) => {
watchSelector(selector.user.preferences, prefs => {
localStorage.setItem('preferences', JSON.stringify(prefs));
});
}Analytics tracking:
onStore: ({ watchSelector, selector }) => {
watchSelector(
[selector.player.currentTrack, selector.player.isPlaying],
([track, isPlaying]) => {
if (track && isPlaying) {
analytics.trackPlay(track.id);
}
}
);
}React Integration
Wrap your app with the Redux Provider and use useSelector with store.selector:
import { Provider, useSelector } from 'react-redux';
import { store } from './store';
// App wrapper
function App() {
return (
<Provider store={store}>
<Counter />
</Provider>
);
}
// Component using selectors
function Counter() {
const count = useSelector(store.selector.counter.count);
const doubled = useSelector(store.selector.counter.doubled);
const user = useSelector(store.selector.user);
return (
<div>
<p>Count: {count} (doubled: {doubled})</p>
<p>User: {user.name}</p>
<button onClick={() => store.dispatch.counter.increment()}>
Increment
</button>
<button onClick={() => store.dispatch.counter.incrementAsync()}>
Increment Async
</button>
</div>
);
}TypeScript
The library is fully typed. Define your models interface for complete type inference:
interface AppModels extends Models<AppModels> {
counter: typeof counter;
user: typeof user;
}
// All dispatch, selector, and select calls are fully typed
store.dispatch.counter.add(5); // payload must be number
store.select.user.name(); // returns string
store.selector.counter.doubled(state); // returns numberLicense
MIT
