bonsai-react
v0.2.0
Published
A minimal isomorphic data store for React and NextJS
Downloads
1,499
Maintainers
Readme
Bonsai
A minimal isomorphic data store for React and NextJS with TypeScript support.
Features
- Minimal: Simple API with just what you need
- Type-safe: Full TypeScript support with strict typing
- Selective: Subscribe to specific parts of your state, down to a single entity in a collection
- Isomorphic: Every hook server-renders; can hydrate from NextJS SSR
- Render-stable: Subscriptions and setters keep their identity across renders, so
React.memoworks
Installation
npm install bonsai-reactyarn add bonsai-reactpnpm add bonsai-reactQuick Start
1. Create a store
import { Store } from 'bonsai-react';
interface AppState {
count: number;
user: { name: string; email: string } | null;
}
const store = new Store<AppState>({
count: 0,
user: null
});2. Create selector hooks
import { createSelectorHook } from 'bonsai-react';
// Subscribe to the entire state
const useAppState = createSelectorHook(store, (state) => state);
// Subscribe to just the count
const useCount = createSelectorHook(store, (state) => state?.count ?? 0);
// Subscribe to just the user
const useUser = createSelectorHook(store, (state) => state?.user);3. Use in your React components
import React from 'react';
function Counter() {
const count = useCount();
return (
<div>
<p>Count: {count}</p>
<button onClick={() => store.update({ count: count + 1 })}>
Increment
</button>
</div>
);
}
function UserProfile() {
const user = useUser();
if (!user) {
return <div>No user logged in</div>;
}
return (
<div>
<h2>Welcome, {user.name}!</h2>
<p>Email: {user.email}</p>
</div>
);
}API Reference
Store<T>
Creates a new store instance.
Constructor
new Store<T>(initialData: T | null)Or extend the Store if you need to handle bootstrap the store using async calls.
class MyStore extends Store<StoreShape> {
constructor(...args) {
super(...args);
myAsyncFn().then((newState) => {
this.update(newState);
});
}
}Methods
update(updates: Partial<T>): void- Updates the store with partial datagetSnapshot(): T | null- Gets the current state snapshotsubscribe(listener: () => void): () => void- Subscribes to all state changessubscribeWithSelector(selector, listener): () => void- Subscribes to specific state changes
The selector contract
Every hook compares the selected value with Object.is. A selector must therefore
return a primitive, a value already held in state, or a module-level constant:
// GOOD - the stored reference is returned as-is
const useVisibleIds = createSelectorHook(store, (state) => state.visibleIds);
// BAD - a fresh array every call. Never compares equal, so this re-renders
// every subscriber on every write, and throws in development.
const useVisibleIds = createSelectorHook(store, (state) => state.ids.filter(Boolean));Derived data belongs in the state, computed by the writer rather than the reader.
Keep the projection in one pure function and apply it in the same update() as
the fields it reads, so the derived value can never go stale:
function project(items: Item[], query: string) {
const visible = query === "" ? items : items.filter((i) => i.name.includes(query));
return { visible, visibleIds: visible.map((i) => i.id) };
}
// every producer of `items` or `query` applies it
export const useQuery = createHook(
store,
(state) => state.query,
(query: string, state) => ({ query, ...project(state.items, query) }),
);createSelectorHook<T, S>(store, selector)
Creates a React hook that subscribes to a specific part of the store. This is a read only hook.
createHook<T, S, U>(store, selector, setter)
Create a React hook that subscribes to changes and also provides a setter. Similar to React's useState.
createKeyedSelectorHook<T, S, K>(store, selectorFor)
Creates a React hook that subscribes to a part of the store identified by a key. This is a read-only hook, useful when state holds a collection of entities (e.g. a collection keyed by id) and you want to subscribe to a single entity. Pass a function that, given a key, returns a selector for that entity:
interface AppState {
posts: Record<string, { id: string; title: string }>;
}
const store = new Store<AppState>({ posts: {} });
// For each key, select the matching entity
const usePost = createKeyedSelectorHook(store, (postId) => (state) => state.posts[postId]);
function Post({ id }: { id: string }) {
const post = usePost(id); // re-renders only when this post changes
return <h2>{post?.title ?? 'Not found'}</h2>;
}Because the subscription is scoped to the selected key, updates to other entities in the collection do not trigger re-renders of components using that key.
Reach for a keyed hook when a large collection takes high-frequency writes to
individual entries — typing, streaming, dragging. For a list updated at human
pace, subscribe to the mapping plus an ordered array of ids and wrap the row in
React.memo; that is cheaper and simpler.
For writes as well as reads, use createKeyedHook.
createKeyedHook<T, S, U, K>(store, selectorFor, setterFor)
The keyed counterpart of createHook: a read-write hook over a single entity in
a collection. Because a plain write selector takes no arguments, this is what a
collection whose entries are edited individually needs — without it, every
per-entity write has to become a wrapper method that takes an id.
interface AppState {
posts: Record<string, { id: string; title: string }>;
}
const store = new Store<AppState>({ posts: {} });
const EMPTY_POST = { id: '', title: '' };
const usePost = createKeyedHook(
store,
(postId: string) => (state: AppState) => state.posts[postId] ?? EMPTY_POST,
(postId: string) => (title: string, state: AppState) => ({
posts: { ...state.posts, [postId]: { ...state.posts[postId], id: postId, title } },
}),
);
function PostTitle({ id }: { id: string }) {
const [post, setTitle] = usePost(id);
// typing here re-renders this row only
return <input value={post.title} onChange={(e) => setTitle(e.target.value)} />;
}The setter is stable for as long as the key is, so it can be passed to a memoised child. Removals stay as methods on the store: a read-write hook has nothing coherent to read once the entity it points at is gone.
See examples folder.
Advanced Usage
You can create multiple stores, combine them, extend the original store and add any kind of functionality you desire. The initial store is designed to be minimal but extensible.
Multiple Stores
You can create multiple stores for different domains:
const userStore = new Store({ user: null, preferences: {} });
const cartStore = new Store({ items: [], total: 0 });
const useUser = createSelectorHook(userStore, (state) => state?.user);
const useCart = createSelectorHook(cartStore, (state) => state?.items ?? []);Complex Selectors
Selectors can compute derived state:
const useCartTotal = createSelectorHook(cartStore, (state) => {
return state?.items.reduce((sum, item) => sum + item.price, 0) ?? 0;
});
const useIsLoggedIn = createSelectorHook(userStore, (state) => {
return state?.user !== null;
});Both of these are safe because they return a primitive. A selector that computes an object or an array must not do it here — see the selector contract. Compute it in the writer and store the result instead.
Combining Multiple Stores
You can combine multiple stores into one larger store for centralized state management:
// Individual stores
const userStore = new Store({ user: null, preferences: {} });
const cartStore = new Store({ items: [], total: 0 });
const uiStore = new Store({ theme: 'light', sidebarOpen: false });
// Combined store interface
interface CombinedState {
user: typeof userStore extends Store<infer U> ? U : never;
cart: typeof cartStore extends Store<infer C> ? C : never;
ui: typeof uiStore extends Store<infer I> ? I : never;
}
// Create a master store that syncs with individual stores
class CombinedStore extends Store<CombinedState> {
constructor() {
super({
user: userStore.getSnapshot(),
cart: cartStore.getSnapshot(),
ui: uiStore.getSnapshot()
});
// Subscribe to individual store changes
userStore.subscribe(() => {
this.update({ user: userStore.getSnapshot() });
});
cartStore.subscribe(() => {
this.update({ cart: cartStore.getSnapshot() });
});
uiStore.subscribe(() => {
this.update({ ui: uiStore.getSnapshot() });
});
}
// Proxy methods to individual stores
updateUser(updates: Parameters<typeof userStore.update>[0]) {
userStore.update(updates);
}
updateCart(updates: Parameters<typeof cartStore.update>[0]) {
cartStore.update(updates);
}
updateUI(updates: Parameters<typeof uiStore.update>[0]) {
uiStore.update(updates);
}
}
const combinedStore = new CombinedStore();
// Use the combined store
const useAppState = createSelectorHook(combinedStore, (state) => state);
const useCombinedUser = createSelectorHook(combinedStore, (state) => state?.user);Server-Side Rendering
Bonsai works great with SSR frameworks like Next.js. Use a provider pattern to pass server-side data and instantiate stores:
import React, { createContext, useContext, useMemo } from 'react';
import { useSyncExternalStore } from 'react';
import { Store } from 'bonsai-react';
interface AppState {
user: { name: string; email: string } | null;
posts: Array<{ id: string; title: string }>;
}
// Create a context for the store
const StoreContext = createContext<Store<AppState> | null>(null);
// Provider component that instantiates the store with SSR data
export function StoreProvider({
children,
initialData
}: {
children: React.ReactNode;
initialData: AppState | null;
}) {
// Memoize store creation to prevent recreation on re-renders
const store = useMemo(() => new Store<AppState>(initialData), [initialData]);
return (
<StoreContext.Provider value={store}>
{children}
</StoreContext.Provider>
);
}
// Hook to get the store instance
function useStore() {
const store = useContext(StoreContext);
if (!store) {
throw new Error('useStore must be used within a StoreProvider');
}
return store;
}
// Create hooks that work with the context
export function useUser() {
const store = useStore();
return useSyncExternalStore(
(listener) => store.subscribeWithSelector((state) => state?.user, listener),
() => store.getSnapshot()?.user ?? null,
() => store.getSnapshot()?.user ?? null
);
}
export function usePosts() {
const store = useStore();
return useSyncExternalStore(
(listener) => store.subscribeWithSelector((state) => state?.posts, listener),
() => store.getSnapshot()?.posts ?? [],
() => store.getSnapshot()?.posts ?? []
);
}
// Next.js usage example
export default function MyApp({ Component, pageProps }: AppProps) {
return (
<StoreProvider initialData={pageProps.storeData}>
<Component {...pageProps} />
</StoreProvider>
);
}
// In your page/API route
export async function getServerSideProps() {
const storeData = {
user: await fetchUser(),
posts: await fetchPosts(),
};
return {
props: {
storeData,
},
};
}TypeScript Support
Bonsai is built with TypeScript and provides full type safety:
interface MyState {
count: number;
items: string[];
}
const store = new Store<MyState>({ count: 0, items: [] });
// TypeScript will enforce the correct shape
store.update({ count: 5 }); // ✅ OK
store.update({ invalid: true }); // ❌ TypeScript error
// Selectors are also type-safe
const useCount = createSelectorHook(store, (state) => {
return state?.count ?? 0; // TypeScript knows this returns number
});Requirements
- React 18.0.0 or higher
- TypeScript 4.5.0 or higher (optional, but recommended)
License
MIT
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
