A lightweight React library for fine-grained reactive state management using Preact Signals, with built-in immutable updates via Mutative, minimal re-renders, and global store support.
createSignalStore
bash
npm install react-synapse
`
or with pnpm:
`bash
pnpm add react-synapse
`
Quick Start
`javascript
import { useReactive } from 'react-synapse';
function Counter() {
const [count, setCount] = useReactive(0);
return (
Count: {count}
);
}
`
Why Signals? Benefits Over Traditional State Management
$3
Traditional state management libraries like Redux, Zustand, or MobX require significant boilerplate:
- Redux: Actions, reducers, action creators, middleware, selectors
- Context API: Provider wrappers, consumer hooks, memoization
With react-synapse, you get a simple, intuitive API:
`javascript
// That's it! No providers, no reducers, no actions
const { store, useStore } = createSignalStore({
user: { name: 'John', age: 30 },
theme: 'light'
})
// In any component - just use it
const [user, setUser] = useStore('user')
`
$3
Traditional Context/Redux approach:
`javascript
// ❌ All components consuming the context re-render
// even when only 'theme' changes
const { user, theme, settings } = useContext(AppContext)
`
Signal-based approach:
`javascript
// ✅ Only components using 'theme' re-render when theme changes
const [theme, setTheme] = useStore('theme')
// This component won't re-render when theme changes!
const [user, setUser] = useStore('user')
`
$3
| Feature | Redux | Context API | Zustand | react-synapse |
|---------|-------|-------------|---------|------------------|
| Boilerplate | High | Medium | Low | Minimal |
| Re-render Scope | Store-wide | Context-wide | Selector-based | Signal-level |
| Bundle Size | ~7kb | Built-in | ~1.5kb | ~3kb |
| Learning Curve | Steep | Low | Low | Minimal |
| TypeScript DX | Good | Manual | Good | Excellent |
| Immutable Updates | Manual/Toolkit | Manual | Manual | Built-in |
$3
Signals track exactly which components depend on which values. This means:
1. No selector functions needed - Unlike Redux where you write selectors to prevent unnecessary renders
2. No useMemo or useCallback optimization - Signals automatically optimize updates
3. No Provider wrappers - State is truly global without wrapping your app
$3
Before (Redux Toolkit):
`javascript
// store.js
const userSlice = createSlice({
name: 'user',
initialState: { name: '', age: 0 },
reducers: {
setName: (state, action) => { state.name = action.payload },
setAge: (state, action) => { state.age = action.payload },
}
})
export const { setName, setAge } = userSlice.actions
// Component.jsx
import { useSelector, useDispatch } from 'react-redux'
import { setName } from './store'
function UserForm() {
const name = useSelector(state => state.user.name)
const dispatch = useDispatch()
return dispatch(setName(e.target.value))} />
}
`
After (react-synapse):
`javascript
// store.js
export const { useStore } = createSignalStore({
user: { name: '', age: 0 }
})
// Component.jsx
import { useStore } from './store'
function UserForm() {
const [user, setUser] = useStore('user')
return value={user.name}
onChange={e => setUser(draft => { draft.name = e.target.value })}
/>
}
`
---
API Reference
$3
Creates a typed global store with multiple signal-based state entries. Returns a store object and a typed useStore hook for accessing state with full TypeScript autocompletion.
Parameters:
- initialStates - An object containing initial values for each store entry
Returns:
- { store, useStore } - An object containing:
- store - The raw store object with all signals
- useStore - A typed React hook for accessing store values
Example:
`typescript
import { createSignalStore } from 'react-synapse';
// Create your store with initial state
const { store, useStore } = createSignalStore({
user: {
username: 'JohnDoe',
age: 30,
preferences: { theme: 'dark', notifications: true }
},
theme: 'light',
todos: [] as { id: number; text: string; done: boolean }[]
});
// Export useStore for use in components
export { useStore };
`
$3
A typed React hook returned from createSignalStore that provides access to store values with full TypeScript autocompletion. Supports two access patterns:
#### Pattern 1: String Key (returns [value, setter])
Parameters:
- key - The string key of the store entry (typed based on initial state)
Returns:
- [value, setter] - A tuple containing:
- value - The current state value (fully typed)
- setter - A function to update the value (supports direct values or draft mutations)
Example:
`tsx
import { useStore } from './store';
function UserProfile() {
// Full autocompletion! user is typed as { username: string, age: number, preferences: {...} }
const [user, setUser] = useStore('user');
// TypeScript knows all the properties
console.log(user.username); // ✓ autocomplete works
console.log(user.age); // ✓ autocomplete works
const updateAge = () => {
// Immer-style draft mutation with full typing
setUser(draft => {
draft.age += 1; // ✓ autocomplete works
draft.preferences.theme = 'light'; // ✓ autocomplete works
});
};
// Or direct value update
const resetUser = () => {
setUser({
username: 'Guest',
age: 0,
preferences: { theme: 'light', notifications: false }
});
};
return (
Hello, {user.username}!
Age: {user.age}
);
}
`
#### Pattern 2: Function Selector (returns value only)
Parameters:
- selector - A function that receives the typed store and returns a signal, array of signals, or object of signals
Returns:
- value - The current value(s) of the selected signal(s) (fully typed)
This pattern is useful when you only need to read a value without updating it, or when you want a more functional style. The selector supports three return types:
##### Selector Returns a Signal Directly
When your selector returns a single Signal, it's used directly for maximum efficiency:
`tsx
import { useStore } from './store';
function ThemeDisplay() {
// Selector returns a Signal directly - used as-is
const theme = useStore(s => s.theme);
// theme is typed as 'light' | 'dark' (or string based on your store)
return Current theme: {theme};
}
function UserStats() {
// Access a single signal directly
const user = useStore(s => s.user);
// user is typed based on your store definition
return (
Name: {user.username}
Age: {user.age}
);
}
`
##### Selector Returns an Array of Signals
When your selector returns an array of Signals, they are automatically wrapped in a computed to make them reactive. The hook returns an array of unwrapped values:
`tsx
import { useStore } from './store';
function MultiValueDisplay() {
// Selector returns an array of Signals - wrapped in computed
const [user, theme, counter] = useStore(s => [s.user, s.theme, s.counter]);
// Each value is unwrapped and reactive
return (
User: {user.name}
Counter: {counter}
);
}
`
##### Selector Returns a Plain Object of Signals
When your selector returns a plain object containing Signals, they are automatically wrapped in a computed to make them reactive. The hook returns an object with unwrapped values:
`tsx
import { useStore } from './store';
function DashboardStats() {
// Selector returns an object of Signals - wrapped in computed
const { currentUser, currentTheme } = useStore(s => ({
currentUser: s.user,
currentTheme: s.theme
}));
// Values are unwrapped and available with your custom keys
return (
Welcome, {currentUser.name}!
);
}
`
##### Re-render Behavior with Function Selectors
> ⚠️ Important: When using the functional approach with arrays or objects, any change to any of the returned state properties or array elements will trigger a re-render of the component. This is because all the selected signals are combined into a single computed signal.
For example:
`tsx
// This component re-renders when EITHER user OR theme OR counter changes
const [user, theme, counter] = useStore(s => [s.user, s.theme, s.counter]);
// This component re-renders when EITHER currentUser OR currentTheme changes
const { currentUser, currentTheme } = useStore(s => ({
currentUser: s.user,
currentTheme: s.theme
}));
// For fine-grained control, use separate useStore calls:
const user = useStore(s => s.user); // Only re-renders on user changes
const theme = useStore(s => s.theme); // Only re-renders on theme changes
`
#### Combining Both Patterns
You can use both patterns in the same component:
`tsx
import { useStore } from './store';
function Dashboard() {
// String key pattern when you need to update
const [settings, setSettings] = useStore('settings');
// Function selector pattern for read-only values
const theme = useStore(s => s.theme);
const notifications = useStore(s => s.notifications);
return (
Notifications ({notifications.length})
);
}
`
$3
A generic React hook for managing global state. For better TypeScript support, prefer using useStore from createSignalStore.
Parameters:
- id - A string identifier for the store entry
- initialState - The initial value (used only if the store entry doesn't exist)
Returns:
- [value, setter] - A tuple with current value and setter function
Example:
`javascript
import { useSignalStore } from 'react-synapse';
function Counter() {
const [count, setCount] = useSignalStore('globalCounter', 0);
return (
);
}
`
$3
A React hook that creates a reactive state with Preact Signals under the hood. Similar to useState, but with enhanced features.
Parameters:
- initialState - The initial value of the state
Returns:
- [state, setState] - A tuple containing the current state and a setter function
Example:
`javascript
import { useReactive } from 'react-synapse';
function TodoList() {
const [todos, setTodos] = useReactive([
{ id: 1, text: 'Learn React', completed: false }
]);
const toggleTodo = (id) => {
// Immer-style draft mutation
setTodos((draft) => {
const todo = draft.find(t => t.id === id);
if (todo) todo.completed = !todo.completed;
});
};
const addTodo = (text) => {
// Direct value update
setTodos((draft) => {
draft.push({ id: Date.now(), text, completed: false });
});
};
return (
{todos.map(todo => (
toggleTodo(todo.id)}>
{todo.text}
))}
);
}
`
$3
A React hook that subscribes to an existing Preact Signal and returns its current value. This is useful for sharing state across components.
Parameters:
- $signal - A Preact Signal instance
Returns:
- state - The current value of the signal
Example:
`javascript
import { createSignal, useReactiveSignal } from 'react-synapse';
// Create a global signal
const $counter = createSignal(0);
function DisplayCounter() {
const count = useReactiveSignal($counter);
return Count: {count}
;
}
function IncrementButton() {
return (
);
}
function App() {
return (
<>
>
);
}
`
$3
Creates a new Preact Signal with an enhanced API that includes an Immer-style setter method.
Parameters:
- initialValue - The initial value of the signal
Returns:
- $signal - A Preact Signal with an additional .set() method
Example:
`javascript
import { createSignal } from 'react-synapse';
const $user = createSignal({
name: 'John',
age: 30,
address: { city: 'New York' }
});
// Immer-style mutation
$user.set((draft) => {
draft.age = 31;
draft.address.city = 'Los Angeles';
});
// Or direct value update
$user.set({ name: 'Jane', age: 25, address: { city: 'Chicago' } });
// Or function returning new value
$user.set((current) => ({ ...current, age: current.age + 1 }));
`
$3
The library also re-exports core Preact Signals functionality:
`javascript
import { signal, effect, computed } from 'react-synapse';
`
- signal(initialValue) - Create a standard Preact Signal
- effect(fn) - Create an effect that runs when signals change
- computed(fn) - Create a derived signal that automatically updates when its dependencies change
Advanced Usage
$3
Here's a full example of setting up a typed global store:
`typescript
// store.ts
import { createSignalStore } from 'react-synapse';
interface User {
id: number;
name: string;
email: string;
}
interface AppState {
user: User | null;
theme: 'light' | 'dark';
notifications: string[];
settings: {
soundEnabled: boolean;
language: string;
};
}
const initialState: AppState = {
user: null,
theme: 'light',
notifications: [],
settings: {
soundEnabled: true,
language: 'en'
}
};
export const { store, useStore } = createSignalStore(initialState);
`
`tsx
// Header.tsx
import { useStore } from './store';
function Header() {
const [theme, setTheme] = useStore('theme');
const [user] = useStore('user');
return (
Welcome, {user?.name ?? 'Guest'}
);
}
`
`tsx
// Settings.tsx
import { useStore } from './store';
function Settings() {
const [settings, setSettings] = useStore('settings');
return (
value={settings.language}
onChange={e => setSettings(draft => {
draft.language = e.target.value;
})}
>
);
}
`
$3
Use Preact's effect for side effects:
Example with effect:
`javascript
import { createSignal, effect } from 'react-synapse';
const $count = createSignal(0);
// Run side effect when signal changes
effect(() => {
console.log('Count changed:', $count.value);
document.title = Count: ${$count.value};
});
`
Use Preact's computed for computed values:
Example with computed:
`javascript
import { createSignal, computed, useReactiveSignal } from 'react-synapse';
const $firstName = createSignal('John');
const $lastName = createSignal('Doe');
// Computed signal: automatically updates when firstName or lastName changes
const $fullName = computed(() => ${$firstName.value} ${$lastName.value});
function Profile() {
const fullName = useReactiveSignal($fullName);
return {fullName}
;
}
// Update signals
$firstName.set('Jane');
// $fullName automatically recomputes to "Jane Doe"
`
How It Works
react-synapse uses React's useSyncExternalStore hook to subscribe to Preact Signals, ensuring compatibility with React 18+ concurrent features. State updates are handled through Mutative, providing Immer-style immutable updates with better performance.
Performance Benefits
- Minimal Re-renders: Only components that read a specific signal value will re-render when it changes
- Efficient Updates: Mutative provides fast immutable updates without the overhead of structural sharing
- Fine-grained Reactivity: Signals allow for precise dependency tracking
- No Provider Hell: Unlike Context API, no need to wrap components in providers
- Automatic Optimization: No need for manual memoization with useMemo or useCallback`