hook-store-bridge
v1.1.4
Published
A utility to bridge React hook state into Zustand or other state management libraries.
Maintainers
Readme
hook-store-bridge
A utility that bridges a React logic hook with a performant state management store (like Zustand), solving state-sharing across components without the performance pitfalls of React Context.
Features
- 💡 Explicit & Predictable: A clear API (
{ tracked, methods }) that makes data flow easy to reason about. - ⚡ Highly Performant: Avoids unnecessary re-renders by design, updating components only when the state they subscribe to actually changes.
- 🎯 Solves Stale Closures: The core pattern robustly handles stale closures for dependent props and state.
- 🚫 No More Prop-Drilling: Share state globally without passing props through dozens of components.
- 🔧 State Library Agnostic: Uses Zustand by default but can be configured to work with any state management library.
- 🛡️ Type-Safe: Fully written in TypeScript with excellent type inference out of the box.
- 🌐 SSR Compatible: Works seamlessly with Server-Side Rendering frameworks like Next.js.
The Problem
Sharing the logic of a powerful custom hook (e.g., Vercel AI SDK's useChat) between distant components is challenging. You typically have two options:
- Prop Drilling: Leads to verbose, hard-to-maintain code.
- React Context: A good solution, but it has a major performance drawback. Any component consuming the Context will re-render whenever any value in the Context changes. For a frequently-updating hook like
useChat, this causes a cascade of unnecessary re-renders, slowing down your app.
The Solution
hook-store-bridge offers a third way. It "lifts" your hook's logic into an optimized store (like Zustand) that supports granular, selector-based subscriptions. This is achieved through a simple but powerful design pattern.
You structure your hook to return two distinct objects:
tracked: An object containing all values that should trigger a component re-render when they change.methods: An object containing all the functions and actions for the store.
This explicit separation gives you the convenience of global state with the best possible rendering performance. A component that only calls a method from methods will not re-render when the tracked state changes.
Installation
npm install hook-store-bridge
# or
yarn add hook-store-bridge
# or
pnpm add hook-store-bridgeQuick Start
Let's create a simple counter store to demonstrate the core concepts.
1. Define Your Logic Hook
First, create a custom hook that defines your store's logic. It must return a { tracked, methods } object.
// useCounterLogic.ts
import { useState, useMemo, useCallback } from 'react'
import type { StoreLogicResult, UseStoreLogic } from 'hook-store-bridge'
export const useCounterLogic = (initialValue = 0) => {
const [count, setCount] = useState(initialValue)
// 1. Define your methods using useCallback for stable references.
const increment = useCallback(() => setCount((c) => c + 1), [])
const decrement = useCallback(() => setCount((c) => c - 1), [])
// 2. Return the state and methods in the required structure.
return {
tracked: { count },
methods: { increment, decrement },
}
}
2. Create the Bridge
Use createHookBridge to create the StoreProvider and useBridgedStore hook.
// counterStore.ts
import { createHookBridge } from 'hook-store-bridge'
import { useCounterLogic } from './useCounterLogic'
export const { useBridgedStore, StoreProvider } = createHookBridge({
useStoreLogic: useCounterLogic,
})3. Provide the Store
Wrap your application or component tree with the StoreProvider.
// App.tsx
import { StoreProvider } from './counterStore'
import { CounterDisplay, CounterControls } from './MyComponents'
function App() {
return (
// You can pass arguments to your logic hook via the `logicArgs` prop.
<StoreProvider logicArgs={[10]}>
<h1>Counter</h1>
<CounterDisplay />
<CounterControls />
</StoreProvider>
)
}4. Use the Store in Any Component
Now, any child component can access the store's state and methods.
CounterDisplay.tsx (Subscribes to state)
This component reads from the tracked state and will re-render when count changes.
import { useBridgedStore } from './counterStore'
export function CounterDisplay() {
const { store } = useBridgedStore()
// Use a selector to subscribe ONLY to the `count` state.
const count = store.use.count()
return <h2>Count: {count}</h2>
}CounterControls.tsx (Only uses methods)
This component only calls methods. Because it does not subscribe to any tracked state, it will not re-render when the count changes, giving you optimal performance.
import { useBridgedStore } from './counterStore'
export function CounterControls() {
// Methods are spread directly onto the hook's return value.
const { increment, decrement } = useBridgedStore()
return (
<div>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
</div>
)
}The Core Principle: tracked vs. methods
To avoid stale closure bugs and ensure your components always have the latest logic, you must follow one simple rule:
If a method's behavior depends on a changing value (like a prop), that method MUST be included in the
trackedstate object.
This ensures that when the dependency changes, a new method instance is created, which in turn updates the tracked state. This triggers a re-render in consumers, providing them with the fresh method from the latest render.
Example: A Logger Hook
Incorrect Usage ❌
Here, log depends on prefix, but the log method itself is not tracked. If the prefix prop changes, components will keep using the old log function, which will log with the old prefix (a stale closure bug).
const useLoggerLogic = ({ prefix }) => {
const methods = {
log: (message: string) => console.log(`[${prefix}]: ${message}`),
}
return {
tracked: {}, // The `log` method is missing!
methods,
}
}Correct Usage ✅
By including the log method in the tracked state, we guarantee that when prefix changes, a new log function is created. This updates the Zustand store, causing consumers to re-render and receive the new log method with the correct prefix in its closure.
const useLoggerLogic: UseStoreLogic<{ prefix: string }, { log: (message: string) => void }, {}> = ({ prefix }) => {
// When `prefix` changes, useCallback creates a new function instance.
const log = useCallback((message: string) => {
console.log(`[${prefix}]: ${message}`)
}, [prefix])
return {
tracked: { log }, // Correct! The dependent method is tracked.
methods: {},
}
}API Reference
createHookBridge(options)
The main function to create your store bridge.
options.useStoreLogic: (Required) The hook that defines the store's core logic. This hook can optionally receive arguments and must return an object with{ tracked, methods }.options.createStoreConfig: (Optional) A function for providing a custom store implementation (e.g., using a different library or adding Zustand middleware).
StoreProvider
A React component that initializes the store and provides it to its children.
props.logicArgs: (Optional) An array of arguments to be passed to youruseStoreLogichook.props.children: The component tree that will have access to the store.
useBridgedStore()
A hook for child components to access the store. It returns an object containing:
store: The Zustand store instance. You can subscribe to state slices with selector hooks (e.g.,store.use.myState()).- All functions from your
methodsobject are spread onto the return value for direct access (e.g.,const { myMethod } = useBridgedStore()).
Utilities
You can also import utility functions directly from the util subpath:
createSelectors(store)
A utility function that adds selector hooks to a Zustand store instance. This allows you to subscribe to specific state slices with store.use.myState() syntax.
import { createSelectors } from 'hook-store-bridge/util'
import { createStore } from 'zustand'
const store = createStore<{ count: number }>(() => ({ count: 0 }))
const storeWithSelectors = createSelectors(store)
// Now you can use selector hooks
const count = storeWithSelectors.use.count()WithSelectors<S>
A TypeScript utility type that adds selector capabilities to a store type.
import type { WithSelectors } from 'hook-store-bridge/util'
import type { StoreApi } from 'zustand'
type MyStore = WithSelectors<StoreApi<{ count: number }>>Custom Store Configuration
You can override the default Zustand setup by providing a createStoreConfig function. This is useful for adding middleware (like Redux DevTools) or integrating a different state library.
See the source code for the default implementation and type definitions.
Example with Redux DevTools:
import { createHookBridge } from 'hook-store-bridge'
import { createStore } from 'zustand'
import { devtools } from 'zustand/middleware'
import { createSelectors } from 'hook-store-bridge/util'
import { useMyStoreLogic } from './useMyStoreLogic'
export const { useBridgedStore, StoreProvider } = createHookBridge({
useStoreLogic: useMyStoreLogic,
createStoreConfig: () => ({
createStore: (initState) => {
const store = createStore<ReturnType<typeof useMyStoreLogic>['tracked']>()(
devtools(() => initState, { name: 'MyStore' }))
return createSelectors(store)
},
updateState: (store, newState) => {
store.setState(newState, false, 'hookStateUpdate')
},
}),
})Examples
Check out the examples directory for complete working examples:
License
This project is licensed under the ISC License - see the LICENSE file for details.

