Getting started
Installation
npm install unstated
Basic Example
import { Container, Subscribe, Provider } from "unstated";
class CounterContainer extends Container {
state = { count: 0 };
increment = () => {
this.setState({ count: this.state.count + 1 });
};
decrement = () => {
this.setState({ count: this.state.count - 1 });
};
}
function Counter() {
return (
<Subscribe to={[CounterContainer]}>
{(counter) => (
<div>
<button onClick={counter.decrement}>-</button>
<span>{counter.state.count}</span>
<button onClick={counter.increment}>+</button>
</div>
)}
</Subscribe>
);
}
// Wrap app with Provider
<Provider>
<Counter />
</Provider>;
Key Concepts
- Container: Class holding state and methods
- Subscribe: Component connecting to containers
- Provider: Top-level wrapper (enables context)
API Reference
Container API
| API | Description |
|---|---|
state |
Current state object |
setState(update) |
Update state (returns Promise) |
subscribe(fn) |
Low-level subscription |
unsubscribe(fn) |
Remove subscription |
Subscribe Props
| Prop | Description |
|---|---|
to |
Array of Container classes or instances |
children |
Render function receiving container instances |
Provider Props
| Prop | Description |
|---|---|
inject |
Optional array of container instances (DI) |
children |
React children |
Library Info
- Version: v2.1.1
- React: ^15.0.0 || ^16.0.0
- TypeScript: Included .d.ts
- Status: ⚠️ Unmaintained since ~2019
Container
Defining a Container
class TodoContainer extends Container {
state = {
todos: [],
loading: false,
};
addTodo = (text) => {
this.setState({
todos: [...this.state.todos, { text, done: false }],
});
};
toggle = (index) => {
this.setState({
todos: this.state.todos.map((todo, i) =>
i === index ? { ...todo, done: !todo.done } : todo,
),
});
};
}
setState Patterns
// Object form
this.setState({ count: this.state.count + 1 });
// Function form (prev state)
this.setState((state) => ({
count: state.count + 1,
}));
// With callback
this.setState(
(state) => ({ count: state.count + 1 }),
() => console.log("Updated!"),
);
// Async/await (setState returns Promise)
await this.setState({ count: 1 });
console.log(this.state.count); // 1
Constructor with Props
class CounterContainer extends Container {
constructor(props = {}) {
super();
this.state = {
amount: props.initialAmount || 1,
count: 0,
};
}
increment = () => {
this.setState({
count: this.state.count + this.state.amount,
});
};
}
const counter = new CounterContainer({ initialAmount: 5 });
Subscribe Component
Single Container
<Subscribe to={[CounterContainer]}>
{(counter) => <span>{counter.state.count}</span>}
</Subscribe>
Subscribe takes an array of Container classes or instances.
Multiple Containers
<Subscribe to={[AppContainer, CounterContainer]}>
{(app, counter) => (
<div>
<span>Amount: {app.state.amount}</span>
<span>Count: {counter.state.count}</span>
<button onClick={() => counter.increment(app.state.amount)}>
+{app.state.amount}
</button>
</div>
)}
</Subscribe>
The render function receives container instances in the same order as the to array.
Shared Instances
const sharedCounter = new CounterContainer();
// Both components share the same state
<Subscribe to={[sharedCounter]}>
{counter => <span>{counter.state.count}</span>}
</Subscribe>
<Subscribe to={[sharedCounter]}>
{counter => <button onClick={counter.increment}>+</button>}
</Subscribe>
⚠️ Passing a class creates a new instance per Provider. Passing an instance shares state.
Async Operations
Async Methods
class TodoContainer extends Container {
state = { todos: [], loading: false, error: null };
fetchTodos = async () => {
await this.setState({ loading: true, error: null });
try {
const res = await fetch("/api/todos");
const todos = await res.json();
await this.setState({ todos, loading: false });
} catch (error) {
await this.setState({
error: error.message,
loading: false,
});
}
};
}
setState() returns a Promise, enabling await and .then() chaining.
Sequential Updates
class TodoContainer extends Container {
addTodo = async (text) => {
const res = await fetch("/api/todos", {
method: "POST",
body: JSON.stringify({ text }),
});
const newTodo = await res.json();
// Wait for setState to complete
await this.setState({
todos: [...this.state.todos, newTodo],
});
console.log("Todo added:", this.state.todos.length);
};
}
Testing
Testing Containers Directly
test("counter", async () => {
let counter = new CounterContainer();
expect(counter.state.count).toBe(0);
await counter.increment();
expect(counter.state.count).toBe(1);
await counter.decrement();
expect(counter.state.count).toBe(0);
});
Containers are plain classes - test them directly without mounting components.
Testing with Provider Inject
test("counter component", async () => {
let counter = new CounterContainer();
render(
<Provider inject={[counter]}>
<Counter />
</Provider>,
);
fireEvent.click(screen.getByText("+"));
expect(counter.state.count).toBe(1);
});
Use Provider.inject to inject container instances for testing.
TypeScript
Typed Container
import { Container, Subscribe, Provider } from "unstated";
interface CounterState {
count: number;
}
class CounterContainer extends Container<CounterState> {
state: CounterState = { count: 0 };
increment = async (): Promise<void> => {
await this.setState({ count: this.state.count + 1 });
};
decrement = async (): Promise<void> => {
await this.setState({ count: this.state.count - 1 });
};
}
Container accepts a generic type parameter for state.
Typed Component
const Counter: React.FC = () => (
<Subscribe to={[CounterContainer]}>
{(counter: CounterContainer) => (
<div>
<button onClick={counter.decrement}>-</button>
<span>{counter.state.count}</span>
<button onClick={counter.increment}>+</button>
</div>
)}
</Subscribe>
);
Annotate the render function parameter for type safety.
Migration to unstated-next
unstated (class-based)
import { Container, Subscribe, Provider } from "unstated";
class CounterContainer extends Container {
state = { count: 0 };
increment = () => this.setState({ count: this.state.count + 1 });
}
function App() {
return (
<Subscribe to={[CounterContainer]}>
{(counter) => <span>{counter.state.count}</span>}
</Subscribe>
);
}
<Provider>
<App />
</Provider>;
unstated-next (hooks-based)
import { createContainer } from "unstated-next";
import { useState } from "react";
function useCounter() {
const [count, setCount] = useState(0);
const increment = () => setCount((c) => c + 1);
return { count, increment };
}
const Counter = createContainer(useCounter);
function App() {
const counter = Counter.useContainer();
return <span>{counter.count}</span>;
}
<Counter.Provider>
<App />
</Counter.Provider>;
unstated-next is only 200 bytes and uses hooks instead of classes.
Patterns
Computed Values
class CartContainer extends Container {
state = { items: [] };
get total() {
return this.state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
}
addItem = (item) => {
this.setState({
items: [...this.state.items, item],
});
};
}
Use getters for derived state.
Container Composition
class AuthContainer extends Container {
state = { user: null };
}
class UserContainer extends Container {
constructor(authContainer) {
super();
this.authContainer = authContainer;
}
state = { profile: null };
loadProfile = async () => {
const userId = this.authContainer.state.user.id;
const profile = await fetch(`/api/users/${userId}`);
this.setState({ profile });
};
}
Pass containers to other containers via constructor.
Middleware Pattern
class LoggerContainer extends Container {
setState(state, callback) {
console.log("Before:", this.state);
super.setState(state, () => {
console.log("After:", this.state);
callback && callback();
});
}
}
Override setState() to add logging, persistence, etc.
Gotchas
Don't Mutate State Directly
// ❌ Bad - mutations don't trigger re-renders
this.state.count = 1;
this.state.todos.push(newTodo);
// ✅ Good - always use setState()
this.setState({ count: 1 });
this.setState({ todos: [...this.state.todos, newTodo] });
setState is Async
// ❌ Bad - state not updated yet
this.setState({ count: 1 });
console.log(this.state.count); // Still 0
// ✅ Good - use await
await this.setState({ count: 1 });
console.log(this.state.count); // 1
// ✅ Good - use function form
this.setState((state) => {
console.log(state.count); // Previous state
return { count: state.count + 1 };
});
Class vs Instance
// ❌ Creates separate instances (state not shared)
<Subscribe to={[CounterContainer]}>
{counter => <span>{counter.state.count}</span>}
</Subscribe>
<Subscribe to={[CounterContainer]}>
{counter => <button onClick={counter.increment}>+</button>}
</Subscribe>
// ✅ Shares state via instance
const sharedCounter = new CounterContainer();
<Subscribe to={[sharedCounter]}>
{counter => <span>{counter.state.count}</span>}
</Subscribe>
<Subscribe to={[sharedCounter]}>
{counter => <button onClick={counter.increment}>+</button>}
</Subscribe>
Library is Unmaintained
⚠️ unstated has not been updated since ~2019. For new projects, use:
- unstated-next - Hooks-based successor (200 bytes)
- Zustand - Modern alternative with hooks
- Jotai - Atomic state management
- React Context + hooks - Built-in solution
Also see
- unstated (github.com) - Original class-based library
- unstated-next (github.com) - Hooks-based successor (200 bytes)
- React Context API (react.dev) - Underlying API
- Zustand (github.com) - Modern alternative