A reactive store library with super fine-grained reactivity powered by alien-signals. Create stores that track property access and update components with surgical precision using MongoDB-style update operators.
📚 View Full Documentation | Core Implementation: packages/core/src | React Integration: packages/react/src | Store: packages/store/src | Examples: packages/react/examples
- 🎯 Super fine-grained reactivity - Only components using changed data re-render
- 🔄 MongoDB-style operators - Powerful update operations with automatic batching
- 📦 Zero boilerplate - No actions, reducers, or decorators required
- ⚛️ React integration - Simple hooks for reactive components
- 📝 Full TypeScript support - Complete type safety and inference
- 🗂️ Document-oriented store - App-level store for document management with promise-like API
- Installation
- Quick Start
- How It Works
- Creating Stores
- Reading State
- Updating State
- React Integration
- MongoDB-Style Operators
- Effects and Computed Values
- Store - Document Management
- Building a TODO App
- TypeScript
- Performance Tips
Package definitions: core/package.json | react/package.json | store/package.json
# Core reactive store
npm install @supergrain/core @supergrain/react
# App-level document store (optional)
npm install @supergrain/store
# or with pnpm
pnpm add @supergrain/core @supergrain/react @supergrain/store
Links: Source Code. Tests.
// [#DOC_TEST_3](packages/documentation/tests/quick-start.test.tsx)
import { createStore } from '@supergrain/core'
import { useTrackedStore } from '@supergrain/react'
// Create a store with initial state
const [store, update] = createStore({
count: 0,
todos: [],
})
// Use in React components
function TodoApp() {
const state = useTrackedStore(store)
// Use update function with MongoDB-style operators
const addTodo = (text: string) => {
update({
$push: {
todos: { id: Date.now(), text, completed: false }
}
})
}
return (
<div>
<h1>Count: {state.count}</h1>
<button onClick={() => update({ $inc: { count: 1 } })}>
Increment
</button>
<input
onKeyPress={(e) => e.key === 'Enter' && addTodo(e.target.value)}
placeholder="Add todo..."
/>
{state.todos.map(todo => (
<div key={todo.id}>{todo.text}</div>
))}
</div>
)
}
Supergrain uses super fine-grained reactivity powered by alien-signals
to automatically track which components access which data, creating subscriptions only to properties that are actually used.
When you call useTrackedStore(store)
in a React component, it:
- Creates an effect context using
alien-signals
- Returns a proxy of your store that tracks property access
- Automatically subscribes to any properties accessed during render
- Re-renders the component only when subscribed properties change
// [#DOC_TEST_28](packages/documentation/tests/readme-examples.test.tsx)
function MyComponent() {
const state = useTrackedStore(store) // Creates reactive proxy
// This creates a subscription to 'user.profile.name'
const name = state.user.profile.name
// This creates a subscription to 'items[0].title'
const firstTitle = state.items[0].title
return <div>{name}: {firstTitle}</div>
}
// Later, when you update:
update({ $set: { 'user.profile.name': 'Jane' } }) // Only this component re-renders
update({ $set: { 'user.profile.age': 30 } }) // This component does NOT re-render
Every property you access during render creates a subscription. The reactivity system:
- ✅
state.items[0].name
creates subscription to ONLY thename
property - ✅
state.items.map(item => item.title)
creates subscriptions to each item'stitle
property - ✅ Deeply nested access like
state.a.b.c.d.e
works perfectly - ✅ Accessing
state.items[0].name
will NOT re-render whenstate.items[0].age
changes (property-level granularity)
Unlike other reactive systems, you never need to manually subscribe or unsubscribe:
// [#DOC_TEST_29](packages/documentation/tests/readme-examples.test.tsx)
// ❌ Other libraries require manual subscriptions
const unsubscribe = store.subscribe('user.name', callback)
useEffect(() => unsubscribe, [])
// ✅ Supergrain: just access the data normally
const userName = useTrackedStore(store).user.name // Automatically subscribed!
Links: Source Code. Tests.
Simple Document
// [#DOC_TEST_1](packages/documentation/tests/creating-stores.test.ts)
import { createStore } from '@supergrain/core'
const [state, update] = createStore({
count: 0,
name: 'John',
})
With nested objects:
// [#DOC_TEST_2](packages/documentation/tests/creating-stores.test.ts)
import { createStore } from '@supergrain/core'
const [state, update] = createStore({
users: [
{
id: 1,
name: 'Alice',
todos: [
{
id: 1,
text: 'Use Supergrain.',
tags: [
{
id: 1,
title: 'Urgent.',
},
],
},
],
address: {
city: 'New York',
zip: '10001',
},
},
],
})
Links: Source Code. Tests.
The state object is a reactive proxy that tracks property access:
// [#DOC_TEST_4](packages/documentation/tests/read-only-state.test.ts)
const [state, update] = createStore({ count: 0, name: 'John' })
// You can read properties normally
console.log(state.count) // 0
console.log(state.name) // 'John'
// Direct mutations are supported
state.count = 5 // ✅ Works fine!
state.name = 'Jane' // ✅ Works fine!
// Update function also works
update({ $set: { count: 10, name: 'Bob' } })
Links: Source Code. Tests.
State can be updated in two ways: direct mutations or the update
function with MongoDB-style operators:
Option 1: Direct mutations (simpler syntax)
// [#DOC_TEST_30](packages/documentation/tests/readme-core.test.ts)
const [state, update] = createStore({
count: 0,
user: { name: 'John', age: 30 },
items: ['a', 'b', 'c'],
})
// Direct mutations work perfectly
state.count = 5
state.user.name = 'Jane'
state.user.age = 35
state.items.push('d')
Option 2: Update function with MongoDB-style operators
// [#DOC_TEST_5](packages/documentation/tests/mongodb-operators.test.ts)
// Set values
update({ $set: { count: 5 } })
update({ $set: { 'user.name': 'Jane' } }) // Dot notation for nested
// Increment numbers
update({ $inc: { count: 1 } })
update({ $inc: { 'user.age': 5 } })
// Array operations
update({ $push: { items: 'd' } })
update({ $pull: { items: 'b' } })
// Multiple operations in one call (batched)
update({
$set: { 'user.name': 'Bob' },
$inc: { count: 2 },
$push: { items: 'e' },
})
Links: Source Code. Tests. Examples.
The primary way to use stores in React:
// [#DOC_TEST_6](packages/documentation/tests/react-integration.test.tsx)
import { useTrackedStore } from '@supergrain/react'
function Counter() {
const state = useTrackedStore(store)
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => update({ $inc: { count: 1 } })}>
Increment
</button>
</div>
)
}
Components only re-render when properties they access change:
// [#DOC_TEST_8](packages/documentation/tests/react-integration.test.tsx)
const [state, update] = createStore({
x: 1,
y: 2,
z: 3
})
function ComponentA() {
const state = useTrackedStore(store)
// Only re-renders when 'x' changes
return <div>X: {state.x}</div>
}
function ComponentB() {
const state = useTrackedStore(store)
// Only re-renders when 'y' changes
return <div>Y: {state.y}</div>
}
// Updating 'z' won't re-render ComponentA or ComponentB
update({ $set: { z: 10 } })
Because values are proxies and they're stable across renders, passing them will break memoized components (as the proxy won't change when the values do). To solve this, call useTrackedStore
inside each memoized component rather than passing state as props:
// [#DOC_TEST_9](packages/documentation/tests/react-integration.test.tsx)
import React, { memo } from 'react'
// ✅ Correct - useTrackedStore inside memoized component
const TaskComponent = memo(({ store, taskId }) => {
const state = useTrackedStore(store)
const task = state.tasks.find(t => t.id === taskId)
return (
<div>
<h3>{task.title}</h3>
<span>{task.completed ? '✓' : '○'}</span>
</div>
)
})
// Usage
function ProjectView() {
const state = useTrackedStore(store)
return (
<div>
{state.project.taskIds.map(taskId => (
<TaskComponent key={taskId} store={store} taskId={taskId} />
))}
</div>
)
}
Links: Source Code. Tests.
The For
component provides optimal performance for rendering arrays by automatically handling version props for React.memo components:
// [#DOC_TEST_10](packages/documentation/tests/react-integration.test.tsx)
import { For } from '@supergrain/react'
// Memoized component for each item
const TodoItem = memo(({ todo }) => (
<div className={todo.completed ? 'completed' : ''}>
{todo.text}
<button onClick={() => toggleTodo(todo.id)}>Toggle</button>
</div>
))
function TodoList() {
const state = useTrackedStore(store)
return (
<For each={state.todos} fallback={<div>No todos yet</div>}>
{(todo, index) => (
<TodoItem key={todo.id} todo={todo} />
)}
</For>
)
}
Links: Source Code. Tests.
// [#DOC_TEST_11](packages/documentation/tests/mongodb-operators.test.ts)
update({ $set: { count: 10 } })
update({ $set: { 'user.name': 'Alice' } }) // Nested with dot notation
update({
$set: {
'user.name': 'Bob',
'user.age': 25,
'settings.theme': 'dark',
},
})
// [#DOC_TEST_12](packages/documentation/tests/mongodb-operators.test.ts)
update({ $unset: { temporaryField: 1 } })
update({ $unset: { 'user.middleName': 1 } })
// [#DOC_TEST_13](packages/documentation/tests/mongodb-operators.test.ts)
update({ $inc: { count: 1 } })
update({ $inc: { count: -5 } }) // Decrement
update({ $inc: { 'stats.views': 10 } })
// [#DOC_TEST_14](packages/documentation/tests/mongodb-operators.test.ts)
update({ $push: { items: 'newItem' } })
// Add multiple items with $each
update({
$push: {
items: { $each: ['item1', 'item2', 'item3'] },
},
})
// [#DOC_TEST_15](packages/documentation/tests/mongodb-operators.test.ts)
// Remove by value
update({ $pull: { items: 'itemToRemove' } })
// Remove objects by matching properties
update({
$pull: {
users: { id: 123, name: 'John' },
},
})
// [#DOC_TEST_16](packages/documentation/tests/mongodb-operators.test.ts)
update({ $addToSet: { tags: 'newTag' } }) // Won't add if already exists
// Add multiple unique items
update({
$addToSet: {
tags: { $each: ['tag1', 'tag2', 'tag3'] },
},
})
// [#DOC_TEST_17](packages/documentation/tests/mongodb-operators.test.ts)
update({ $rename: { oldFieldName: 'newFieldName' } })
update({ $rename: { 'user.firstName': 'user.name' } })
// [#DOC_TEST_18](packages/documentation/tests/mongodb-operators.test.ts)
// Only updates if new value is smaller
update({ $min: { lowestScore: 50 } })
// Only updates if new value is larger
update({ $max: { highestScore: 100 } })
Links: Source Code. Examples.
React to state changes with effect
:
// [#DOC_TEST_19](packages/documentation/tests/effects.test.ts)
import { effect } from '@supergrain/core'
const [state, update] = createStore({ count: 0 })
// This runs whenever count changes
effect(() => {
console.log('Count changed to:', state.count)
})
// Save to localStorage on change
effect(() => {
localStorage.setItem('count', String(state.count))
})
Derive values that update automatically:
// [#DOC_TEST_20](packages/documentation/tests/computed.test.ts)
import { computed } from '@supergrain/core'
const [state, update] = createStore({
todos: [
{ id: 1, text: 'Task 1', completed: false },
{ id: 2, text: 'Task 2', completed: true },
],
})
const completedCount = computed(
() => state.todos.filter(t => t.completed).length
)
console.log(completedCount()) // 1
// Updates automatically when todos change
update({
$set: { 'todos.0.completed': true },
})
console.log(completedCount()) // 2
Links: Source Code.
The @supergrain/store
package provides a document-oriented store built on top of the core Supergrain reactivity system. It's designed for managing app-level data with a promise-like reactive API.
Define your document types and create a Store:
// [#DOC_TEST_21](packages/documentation/tests/store.test.tsx)
import { Store } from '@supergrain/store'
interface DocumentTypes {
users: {
id: number
firstName: string
lastName: string
email: string
}
posts: {
id: number
title: string
content: string
userId: number
}
}
// Create store with optional fetch handler
const store = new Store<DocumentTypes>(async (modelType, id) => {
const response = await fetch(`/api/${modelType}/${id}`)
return response.json()
})
// Or without fetch handler (manual data management)
const store = new Store<DocumentTypes>()
// [#DOC_TEST_22](packages/documentation/tests/store.test.tsx)
// Get a document (returns immediately, fetches if not cached)
const doc = store.findDoc('posts', 1)
// Document States - Documents have a promise-like API with these properties:
doc.content // T | undefined - The document data
doc.isPending // boolean - Request in progress
doc.isSettled // boolean - Request completed (success or failure)
doc.isRejected // boolean - Request failed
doc.isFulfilled // boolean - Request succeeded
// [#DOC_TEST_23](packages/documentation/tests/store.test.tsx)
// Set document directly
store.setDocument('users', 1, {
id: 1,
firstName: 'Jane',
lastName: 'Smith',
email: 'jane@example.com',
})
const user = store.findDoc('users', 1)
console.log(user.isFulfilled) // true
console.log(user.content) // { id: 1, firstName: 'Jane', ... }
// Handle errors
store.setDocumentError('users', 999, 'User not found')
const errorUser = store.findDoc('users', 999)
console.log(errorUser.isRejected) // true
// [#DOC_TEST_24](packages/documentation/tests/store.test.tsx)
// Shows as pending immediately, then fulfilled when complete
const newUserPromise = store.insertDocument('users', {
id: 123,
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
})
// Document is immediately available to other components
const user = store.findDoc('users', 123)
console.log(user.isPending) // true initially
const newUser = await newUserPromise
console.log(user.isFulfilled) // true after promise resolves
// [#DOC_TEST_25](packages/documentation/tests/store.test.tsx)
function MyComponent() {
// Documents are fetched automatically and cached
const post = store.findDoc('posts', 1)
const user = store.findDoc('users', post.content?.userId)
if (post.isPending) return <div>Loading post...</div>
if (post.isRejected) return <div>Error loading post</div>
return (
<article>
<h1>{post.content?.title}</h1>
{user.content && (
<p>By: {user.content.firstName} {user.content.lastName}</p>
)}
</article>
)
}
Links: Source Code.
Here's a complete TODO application demonstrating Supergrain's features:
// [#DOC_TEST_26](packages/documentation/tests/todo-app.test.tsx)
import { createStore } from '@supergrain/core'
import { useTrackedStore, For } from '@supergrain/react'
import { memo } from 'react'
interface Todo {
id: number
text: string
completed: boolean
}
// Create store
const [store, update] = createStore({
todos: [] as Todo[],
filter: 'all' as 'all' | 'active' | 'completed',
newTodoText: '',
})
// Memoized todo item component
const TodoItem = memo(({ todo }: { todo: Todo }) => {
const toggleTodo = () => {
const index = store.todos.findIndex(t => t.id === todo.id)
update({
$set: { [`todos.${index}.completed`]: !todo.completed }
})
}
const deleteTodo = () => {
update({ $pull: { todos: { id: todo.id } } })
}
return (
<div className={todo.completed ? 'completed' : 'pending'}>
<input
type="checkbox"
checked={todo.completed}
onChange={toggleTodo}
/>
<span>{todo.text}</span>
<button onClick={deleteTodo}>Delete</button>
</div>
)
})
function TodoApp() {
const state = useTrackedStore(store)
const addTodo = () => {
if (state.newTodoText.trim()) {
update({
$push: {
todos: {
id: Date.now(),
text: state.newTodoText,
completed: false
}
},
$set: { newTodoText: '' }
})
}
}
const filteredTodos = state.todos.filter(todo => {
if (state.filter === 'active') return !todo.completed
if (state.filter === 'completed') return todo.completed
return true
})
return (
<div>
<h1>TODO App</h1>
<div>
<input
value={state.newTodoText}
onChange={e => update({ $set: { newTodoText: e.target.value } })}
onKeyPress={e => e.key === 'Enter' && addTodo()}
placeholder="What needs to be done?"
/>
<button onClick={addTodo}>Add</button>
</div>
<div>
{['all', 'active', 'completed'].map(filter => (
<button
key={filter}
className={state.filter === filter ? 'active' : ''}
onClick={() => update({ $set: { filter } })}
>
{filter}
</button>
))}
</div>
<For each={filteredTodos} fallback={<p>No todos to show</p>}>
{todo => <TodoItem key={todo.id} todo={todo} />}
</For>
<div>
Total: {state.todos.length} |
Active: {state.todos.filter(t => !t.completed).length} |
Completed: {state.todos.filter(t => t.completed).length}
</div>
</div>
)
}
Supergrain provides full TypeScript support with type inference and type safety:
// [#DOC_TEST_27](packages/documentation/tests/typescript.test.ts)
interface AppState {
user: {
name: string
age: number
preferences: {
theme: 'light' | 'dark'
notifications: boolean
}
}
items: Array<{ id: string; title: string; count: number }>
}
const [store, update] = createStore<AppState>({
user: {
name: 'John',
age: 30,
preferences: {
theme: 'light',
notifications: true
}
},
items: []
})
// TypeScript will enforce correct types in updates
update({
$set: {
'user.name': 'Jane', // ✅ string
'user.age': 'invalid' // ❌ TypeScript error - must be number
},
$push: {
items: {
id: '1',
title: 'Item 1',
count: 5 // ✅ All required fields
}
}
})
// Component usage is also type-safe
function UserProfile() {
const state = useTrackedStore(store)
return (
<div>
<h1>{state.user.name}</h1> {/* ✅ TypeScript knows this is string */}
<p>Age: {state.user.age}</p> {/* ✅ TypeScript knows this is number */}
</div>
)
}
-
Use React.memo for list items - When rendering arrays, wrap item components with
React.memo
or use theFor
component for automatic optimization -
Access only needed properties - The more specific your property access, the fewer re-renders you'll get
-
Batch updates when possible - Multiple operations in one
update()
call are automatically batched -
Use computed values for derived state - Instead of recalculating in components, use
computed()
for efficient caching -
Avoid accessing array length in hot paths -
state.items.length
creates a subscription to the entire array; consider tracking count separately if needed -
Profile with React DevTools - Use the React DevTools Profiler to identify unnecessary re-renders
The reactive system is designed to be fast by default, but following these patterns will help you achieve optimal performance in complex applications.
Contributions are welcome! Please feel free to submit a Pull Request.
# Clone the repository
git clone https://github.com/commoncurriculum/supergrain.git
cd supergrain
# Install dependencies
pnpm install
# Build packages
pnpm -r --filter="@supergrain/*" build
# Run tests
pnpm test
# Run type checks
pnpm run typecheck
This project uses Changesets for automated releases. You can create changesets via:
- GitHub UI: Use the Add Changeset workflow (no terminal needed!)
- Terminal: Run
pnpm changeset
GitHub Actions automatically handles versioning, changelogs, and publishing to NPM.
Documentation:
- NPM_SETUP.md - Complete guide for setting up NPM publishing (tokens, scoped packages, troubleshooting)
- RELEASING.md - Step-by-step instructions for creating releases
MIT