NexusCS

Unstated

React
Unstated is a minimal React state management library built on the Context API. Uses Container classes with familiar setState(). Note: Unmaintained since ~2019; see unstated-next for a hooks-based successor.
react
state-management
context-api

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