@low-orbit/bruin
v1.2.3
Published
🐻 History-enabled state management for React, built on Zustand
Maintainers
Readme
A lightweight, performant state management library built on simplified flux patterns. Features a hook-based API that's straightforward and flexible, without unnecessary complexity. Every store automatically tracks history for undo/redo functionality.
While the bear mascot is friendly, the library is robust. Significant effort went into handling React's tricky edge cases, including the zombie child problem, react concurrency, and context loss between mixed renderers. Bruin handles these challenges correctly.
Built on Zustand for compatibility. If you're familiar with Zustand, you'll feel right at home with Bruin. The difference is automatic history tracking.
npm install @low-orbit/bruinCreating a store
Stores are created as hooks. Store any data type: primitives, objects, or functions. Updates must be immutable, and the set function merges state automatically. History tracking happens automatically for every change.
import { create } from '@low-orbit/bruin'
interface BearState {
bears: number
increasePopulation: () => void
removeAllBears: () => void
}
const useBearStore = create<BearState>((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}))Use in your components
Use the store hook anywhere in your component tree—no providers required. Select the state you need and components re-render when those values change. Undo and redo are available directly on the store instance.
function BearCounter() {
const bears = useBearStore((state) => state.bears)
return <h1>{bears} around here ...</h1>
}
function Controls() {
const increasePopulation = useBearStore((state) => state.increasePopulation)
const { undo, redo, canUndo, canRedo } = useBearStore
return (
<>
<button onClick={increasePopulation}>one up</button>
<button onClick={undo} disabled={!canUndo}>undo</button>
<button onClick={redo} disabled={!canRedo}>redo</button>
</>
)
}Why choose Bruin over Redux?
- Minimal setup with flexible patterns
- Hooks-first approach for consuming state
- No context provider wrappers needed
- Supports transient updates without re-renders
- Built-in undo/redo - History tracking without additional libraries
Why choose Bruin over Context API?
- Reduced boilerplate code
- Selective re-renders based on state changes
- Centralized state with action-based updates
- Automatic history - Every change is tracked automatically
Why choose Bruin over Zustand?
- Full Zustand compatibility, with additional features:
- Automatic undo/redo - History built into every store
- Transactions - Batch multiple updates together
- History persistence - Save history across sessions with
persistHistory
Use Cases
Bruin's history and snapshot functionality is ideal for:
- Design tools - Image editors, UI builders, animation tools
- Form builders - Dynamic forms, CMS panels, configuration editors
- Data visualization - Analytics dashboards, chart builders, BI tools
- Code editors - Online IDEs, query builders, API testing tools
- Game development - Level editors, character creators, asset placement tools
→ See the History Example for headless components and UI integrations that demonstrate building history visualization UIs.
Recipes
Undo/redo history
Bruin adds automatic undo/redo history to every store:
interface BearState {
bears: number
increasePopulation: () => void
}
const useBearStore = create<BearState>((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
}))
// Undo/redo methods available on the store
useBearStore.undo() // Go back one step (restores entire state)
useBearStore.redo() // Go forward one step (restores entire state)
useBearStore.canUndo() // Check if undo is possible
useBearStore.canRedo() // Check if redo is possibleImportant: Undo/redo restores the entire state to the previous snapshot (not just changed fields). Each store maintains its own independent history - calling undo() on one store doesn't affect others.
Named Snapshots
Save and restore specific state checkpoints by name:
interface CounterState {
count: number
increment: () => void
}
const useCounterStore = create<CounterState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
// Save a snapshot
const snapshotId = useCounterStore.getState().saveSnapshot('checkpoint-1')
// Make changes
useCounterStore.getState().increment()
useCounterStore.getState().increment()
// Restore the snapshot (adds to history)
useCounterStore.getState().loadSnapshot(snapshotId)
// List all snapshots
const snapshots = useCounterStore.getState().listSnapshots()
// [{ id: '...', name: 'checkpoint-1', timestamp: 1234567890 }]
// Get snapshot details
const snapshot = useCounterStore.getState().getSnapshot(snapshotId)
// Delete a snapshot
useCounterStore.getState().deleteSnapshot(snapshotId)
// Clear all snapshots
useCounterStore.getState().clearSnapshots()Key points:
- Snapshots are independent from history - they persist even if history is cleared
- Loading a snapshot always adds a new history entry
- Snapshots are deep-cloned to prevent reference sharing
- Useful for saving "before experiment" states, templates, or checkpoints
Transactions
Transactions group multiple changes into a single history entry:
interface StoreState {
count: number
name: string
initialize: () => void
}
// Option 1: Using set.transaction (recommended)
const useStore = create<StoreState>((set) => ({
count: 0,
name: '',
initialize: () =>
set.transaction(
() => {
set({ count: 1 })
set({ name: 'John' })
},
{ name: 'Initialize User' },
),
}))
// Option 2: Using store.transaction
useStore.transaction(
() => {
useStore.setState({ count: 1 })
useStore.setState({ name: 'John' })
},
{ name: 'Initialize User' },
)Accessing all state
You can access the entire state object, but this will cause the component to re-render whenever any part of the state changes.
const state = useBearStore()Selecting multiple state values
By default, Bruin uses strict equality (===) to detect changes, which works efficiently for primitive value selections.
const nuts = useBearStore((state) => state.nuts)
const honey = useBearStore((state) => state.honey)When selecting multiple values into a single object (similar to Redux's mapStateToProps), use useShallow to prevent re-renders when the selected values haven't changed according to shallow equality.
import { create } from '@low-orbit/bruin'
import { useShallow } from '@low-orbit/bruin/react/shallow'
interface BearStore {
nuts: number
honey: number
treats: Record<string, unknown>
}
const useBearStore = create<BearStore>((set) => ({
nuts: 0,
honey: 0,
treats: {},
}))
// Object pick, re-renders the component when either state.nuts or state.honey change
const { nuts, honey } = useBearStore(
useShallow((state) => ({ nuts: state.nuts, honey: state.honey })),
)
// Array pick, re-renders the component when either state.nuts or state.honey change
const [nuts, honey] = useBearStore(
useShallow((state) => [state.nuts, state.honey]),
)
// Mapped picks, re-renders the component when state.treats changes in order, count or keys
const treats = useBearStore(useShallow((state) => Object.keys(state.treats)))For advanced re-render control, you can provide a custom equality function (this requires using createWithEqualityFn).
const treats = useBearStore(
(state) => state.treats,
(oldTreats, newTreats) => compare(oldTreats, newTreats),
)Replacing state entirely
The set function accepts a second parameter (defaults to false). When set to true, it replaces the entire state instead of merging. Take care not to accidentally remove important parts like action functions.
interface FishState {
salmon: number
tuna: number
deleteEverything: () => void
deleteTuna: () => void
}
const useFishStore = create<FishState>((set) => ({
salmon: 1,
tuna: 2,
deleteEverything: () => set({}, true), // clears the entire store, actions included
deleteTuna: () => set(({ tuna, ...rest }) => rest, true),
}))Asynchronous actions
Bruin works seamlessly with async functions. Simply call set whenever your async operation completes.
interface FishState {
fishies: Record<string, unknown>
fetch: (pond: string) => Promise<void>
}
const useFishStore = create<FishState>((set) => ({
fishies: {},
fetch: async (pond: string) => {
const response = await fetch(pond)
set({ fishies: await response.json() })
},
}))Reading state within actions
While set supports function updates like set(state => result), you can also access the current state using get without triggering an update.
interface SoundState {
sound: string
action: () => void
}
const useSoundStore = create<SoundState>((set, get) => ({
sound: 'grunt',
action: () => {
const sound = get().sound
// ...
},
}))Accessing stores outside React components
When you need to read or modify state outside of React components, the store hook exposes utility methods directly.
:warning: This technique is not recommended for adding state in React Server Components (typically in Next.js 13 and above). It can lead to unexpected bugs and privacy issues for your users. For more details, see #2200.
interface DogState {
paw: boolean
snout: boolean
fur: boolean
}
const useDogStore = create<DogState>(() => ({ paw: true, snout: true, fur: true }))
// Getting non-reactive fresh state
const paw = useDogStore.getState().paw
// Listening to all changes, fires synchronously on every change
const unsub1 = useDogStore.subscribe(console.log)
// Updating state, will trigger listeners
useDogStore.setState({ paw: false })
// Undo/redo available
useDogStore.undo()
useDogStore.redo()
// Unsubscribe listeners
unsub1()
// You can of course use the hook as you always would
function Component() {
const paw = useDogStore((state) => state.paw)
// ...
}Subscribing to specific state slices
To subscribe only to specific parts of state, use the subscribeWithSelector middleware.
This middleware extends subscribe with an additional signature:
subscribe(selector, callback, options?: { equalityFn, fireImmediately }): Unsubscribeimport { subscribeWithSelector } from '@low-orbit/bruin/middleware'
interface DogState {
paw: boolean
snout: boolean
fur: boolean
}
const useDogStore = create<DogState>(
subscribeWithSelector(() => ({ paw: true, snout: true, fur: true })),
)
// Listening to selected changes, in this case when "paw" changes
const unsub2 = useDogStore.subscribe((state) => state.paw, console.log)
// Subscribe also exposes the previous value
const unsub3 = useDogStore.subscribe(
(state) => state.paw,
(paw, previousPaw) => console.log(paw, previousPaw),
)
// Subscribe also supports an optional equality function
const unsub4 = useDogStore.subscribe(
(state) => [state.paw, state.fur],
console.log,
{ equalityFn: shallow },
)
// Subscribe and fire immediately
const unsub5 = useDogStore.subscribe((state) => state.paw, console.log, {
fireImmediately: true,
})Using Bruin without React
Bruin's core functionality works without React. When using the vanilla version, createStore returns store utilities instead of a React hook.
import { createStore } from '@low-orbit/bruin/vanilla'
interface StoreState {
count: number
inc: () => void
}
const store = createStore<StoreState>((set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
}))
const { getState, setState, subscribe, undo, redo } = store
export default storeVanilla stores can be used with the useStore hook (available since v4).
import { useStore } from '@low-orbit/bruin'
import { vanillaStore } from './vanillaStore'
const useBoundStore = <T,>(selector: (state: typeof vanillaStore extends { getState: () => infer S } ? S : never) => T) =>
useStore(vanillaStore, selector):warning: Note that middlewares that modify set or get are not applied to getState and setState.
Transient updates for frequent state changes
For state that changes frequently, subscribe lets you listen without triggering re-renders. Combine with useEffect to automatically clean up subscriptions on unmount. This provides significant performance benefits when you can update the DOM directly.
import { useRef, useEffect } from 'react'
interface ScratchState {
scratches: number
}
const useScratchStore = create<ScratchState>((set) => ({ scratches: 0 }))
const Component = () => {
// Fetch initial state
const scratchRef = useRef(useScratchStore.getState().scratches)
// Connect to the store on mount, disconnect on unmount, catch state-changes in a reference
useEffect(() => useScratchStore.subscribe(
state => (scratchRef.current = state.scratches)
), [])
// ...
}Working with nested state? Try Immer
Updating deeply nested state structures can be tedious. Immer makes it much easier.
import { produce } from 'immer'
interface LushState {
lush: {
forest: {
contains: { a: string } | null
}
}
clearForest: () => void
}
const useLushStore = create<LushState>((set) => ({
lush: { forest: { contains: { a: 'bear' } } },
clearForest: () =>
set(
produce((state) => {
state.lush.forest.contains = null
}),
),
}))
const clearForest = useLushStore((state) => state.clearForest)
clearForest()Alternatively, there are some other solutions.
Persist middleware
Save your store's state to any storage backend using the persist middleware.
import { create } from '@low-orbit/bruin'
import { persist, createJSONStorage } from '@low-orbit/bruin/middleware'
interface FishState {
fishes: number
addAFish: () => void
}
const useFishStore = create<FishState>()(
persist(
(set, get) => ({
fishes: 0,
addAFish: () => set({ fishes: get().fishes + 1 }),
}),
{
name: 'food-storage', // name of the item in the storage (must be unique)
storage: createJSONStorage(() => sessionStorage), // (optional) by default, 'localStorage' is used
persistHistory: true, // NEW: Persist undo/redo history across sessions
},
),
)See the full documentation for this middleware.
Immer middleware
Bruin includes Immer as a middleware option for easier nested state updates.
import { create } from '@low-orbit/bruin'
import { immer } from '@low-orbit/bruin/middleware/immer'
interface BeeState {
bees: number
addBees: (by: number) => void
}
const useBeeStore = create<BeeState>()(
immer((set) => ({
bees: 0,
addBees: (by: number) =>
set((state) => {
state.bees += by
}),
})),
)Prefer Redux-style reducers?
You can implement reducer patterns manually:
interface GrumpyState {
grumpiness: number
dispatch: (action: { type: 'INCREASE' | 'DECREASE'; by?: number }) => void
}
const types = { increase: 'INCREASE' as const, decrease: 'DECREASE' as const }
const reducer = (state: GrumpyState, action: { type: 'INCREASE' | 'DECREASE'; by?: number }) => {
switch (action.type) {
case types.increase:
return { grumpiness: state.grumpiness + (action.by ?? 1) }
case types.decrease:
return { grumpiness: state.grumpiness - (action.by ?? 1) }
}
}
const useGrumpyStore = create<GrumpyState>((set) => ({
grumpiness: 0,
dispatch: (args) => set((state) => reducer(state, args)),
}))
const dispatch = useGrumpyStore((state) => state.dispatch)
dispatch({ type: types.increase, by: 2 })Alternatively, use the redux middleware which configures your reducer, sets initial state, and adds dispatch to both the state and vanilla API.
import { redux } from '@low-orbit/bruin/middleware'
const useGrumpyStore = create(redux(reducer, initialState))Redux DevTools integration
Use the Redux DevTools Chrome extension with Bruin's devtools middleware.
import { devtools } from '@low-orbit/bruin/middleware'
interface PlainState {
// your state here
}
// Usage with a plain action store, it will log actions as "setState"
const usePlainStore = create<PlainState>()(devtools((set) => ({ /* ... */ })))
// Usage with a redux store, it will log full action types
const useReduxStore = create(devtools(redux(reducer, initialState)))Multiple stores with DevTools
Connect multiple stores to DevTools:
import { devtools } from '@low-orbit/bruin/middleware'
// Plain stores log actions as "setState"
const usePlainStore1 = create<PlainState>()(devtools((set) => ({ /* ... */ }), { name: 'Store1', store: 'storeName1' }))
const usePlainStore2 = create<PlainState>()(devtools((set) => ({ /* ... */ }), { name: 'Store2', store: 'storeName2' }))
// Redux stores log full action types
const useReduxStore1 = create(devtools(redux(reducer, initialState), { name: 'ReduxStore1', store: 'storeName3' }))
const useReduxStore2 = create(devtools(redux(reducer, initialState), { name: 'ReduxStore2', store: 'storeName4' }))Different connection names separate stores in DevTools and allow grouping related stores together.
The devtools middleware accepts the store function as the first argument. The second argument can include a store name or serialize configuration.
Store naming: devtools(..., {name: "MyStore"}) creates a separate DevTools instance named "MyStore".
Serialization: devtools(..., { serialize: { options: true } }) configures serialization options.
Action Logging
Each store logs actions independently (unlike Redux's combined reducers). For combining stores, see https://github.com/pmndrs/zustand/issues/163
You can log a specific action type for each set function by passing a third parameter:
interface BearState {
fishes: number
eatFish: () => void
}
const useBearStore = create<BearState>()(devtools((set) => ({
fishes: 0,
eatFish: () => set(
(prev) => ({ fishes: prev.fishes > 1 ? prev.fishes - 1 : 0 }),
undefined,
'bear/eatFish'
),
})))You can also log the action's type along with its payload:
interface BearState {
fishes: number
addFishes: (count: number) => void
}
const useBearStore = create<BearState>()(devtools((set) => ({
fishes: 0,
addFishes: (count: number) => set(
(prev) => ({ fishes: prev.fishes + count }),
undefined,
{ type: 'bear/addFishes', count }
),
})))If an action type is not provided, it is defaulted to "anonymous". You can customize this default value by providing an anonymousActionType parameter:
devtools(..., { anonymousActionType: 'unknown', ... })If you wish to disable devtools (on production for instance). You can customize this setting by providing the enabled parameter:
devtools(..., { enabled: false, ... })Using React Context
Stores created with create don't need context providers. However, you might want to use Context for dependency injection or to initialize stores with component props. Since stores are hooks, passing them directly as context values can violate React's rules of hooks.
The recommended approach (available since v4) is to use a vanilla store with Context.
import { createContext, useContext } from 'react'
import { createStore, useStore } from '@low-orbit/bruin'
interface StoreState {
// your state here
}
const store = createStore<StoreState>((set) => ({ /* ... */ })) // vanilla store without hooks
const StoreContext = createContext<typeof store | null>(null)
const App = () => (
<StoreContext.Provider value={store}>
{/* ... */}
</StoreContext.Provider>
)
const Component = () => {
const store = useContext(StoreContext)
if (!store) throw new Error('Store not found')
const slice = useStore(store, (state) => state) // selector
// ...
}TypeScript Usage
Bruin is written in TypeScript and provides excellent type inference. All examples in this README use TypeScript.
import { create } from '@low-orbit/bruin'
import { devtools, persist } from '@low-orbit/bruin/middleware'
import type {} from '@redux-devtools/extension' // required for devtools typing
interface BearState {
bears: number
increase: (by: number) => void
}
const useBearStore = create<BearState>()(
devtools(
persist(
(set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}),
{
name: 'bear-storage',
},
),
),
)A more detailed TypeScript guide is here and there.
Third-Party Libraries
The community has created various extensions and integrations for Bruin. See third-party libraries documentation for available options.
Comparing with other libraries
How Bruin Extends Zustand
Bruin maintains full compatibility with Zustand while adding:
- ✅ Automatic undo/redo - Every store tracks history automatically
- ✅ Transactions - Batch multiple updates into single history entries
- ✅ History persistence - Save history across sessions with
persistHistory - ✅ Path subscriptions - Subscribe to specific nested object paths
- ✅ Structural sharing - Efficient immutable updates via built-in Immer
All Zustand features and patterns work identically in Bruin.
License
MIT - Derivative work based on Zustand. See LICENSE for attribution.
