A type-safe, object-oriented React state management library built on useSyncExternalStore. @garbles/external-store provides a structured approach to state management with first-class TypeScript support, powerful testing capabilities, and clean separation of concerns.
It's essentially an object-oriented zustand but with slightly better compile-time support for test mocking.
- Object-Oriented: Clean class-based architecture with encapsulated state and methods
- Type-Safe: Full TypeScript support with automatic action extraction and type inference
- Easy Testing: Test state logic without React - direct method calls and type-safe mocking
- React-Optimized: Built on
useSyncExternalStorefor concurrent-safe updates - Selective Subscriptions: Components only re-render when selected state changes
- Provider Pattern: Optional dependency injection with provider/hook pairs
- Type-Safe Mocking: Override methods with compile-time safety using TypeScript's
overridekeyword
npm install @garbles/external-store
# or
yarn add @garbles/external-store
# or
pnpm add @garbles/external-storeimport { ExternalStore } from "@garbles/external-store";
// 1. Create your store class
class CounterStore extends ExternalStore<{ count: number }> {
constructor() {
super({ count: 0 });
}
increment() {
this.setState((prev) => ({ count: prev.count + 1 }));
}
decrement() {
this.setState((prev) => ({ count: prev.count - 1 }));
}
reset() {
this.setState({ count: 0 });
}
}
// 2. Create store instance
const counterStore = new CounterStore();
// 3. Use in React components
function Counter() {
const [state, actions] = counterStore.use();
return (
<div>
<h1>Count: {state.count}</h1>
<button onClick={actions.increment}>+</button>
<button onClick={actions.decrement}>-</button>
<button onClick={actions.reset}>Reset</button>
</div>
);
}
// 4. Use with selectors for performance
function CountDisplay() {
const [count] = counterStore.use((state) => state.count);
return <span>{count}</span>;
}Create provider/hook pairs for dependency injection and testing:
// Create provider and hook
const [CounterProvider, useCounter] = ExternalStore.createProvider(counterStore);
// Use the provider in your app
function App() {
return (
<CounterProvider store={counterStore}>
<Counter />
</CounterProvider>
);
}
// Use the hook in components
function Counter() {
const [state, actions] = useCounter();
return <button onClick={actions.increment}>{state.count}</button>;
}Define contracts with abstract classes:
abstract class AbstractCounterStore extends ExternalStore<{ count: number }> {
constructor() {
super({ count: 0 });
}
abstract increment(): void;
abstract decrement(): void;
}
class CounterStore extends AbstractCounterStore {
increment() {
this.setState((prev) => ({ count: prev.count + 1 }));
}
decrement() {
this.setState((prev) => ({ count: prev.count - 1 }));
}
}
// Create abstract provider
const [AbstractCounterProvider, useAbstractCounter] = ExternalStore.createProvider<AbstractCounterStore>("CounterStore");
// Use the provider in your app
function App() {
return (
<AbstractCounterProvider store={counterStore}>
<Counter />
</AbstractCounterProvider>
);
}
// Use the hook in components
function Counter() {
// `actions` are only the _public_ functions defined on the parent class.
const [state, actions] = useCounter();
return <button onClick={actions.increment}>{state.count}</button>;
}Handle async operations with loading states:
class AsyncCounterStore extends ExternalStore<{
count: number;
loading: boolean;
}> {
constructor() {
super({ count: 0, loading: false });
}
async incrementAsync() {
this.setState({ loading: true });
try {
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
this.setState((prev) => ({
count: prev.count + 1,
loading: false,
}));
} catch (error) {
this.setState({ loading: false });
}
}
}Test store logic without React:
import { CounterStore } from "./CounterStore";
test("counter increments correctly", () => {
const store = new CounterStore();
expect(store.state.count).toBe(0);
store.increment();
expect(store.state.count).toBe(1);
store.increment();
expect(store.state.count).toBe(2);
});import { renderHook, act } from "@testing-library/react";
test("counter hook works correctly", () => {
const store = new CounterStore();
const { result } = renderHook(() => store.use());
expect(result.current[0].count).toBe(0);
act(() => {
result.current[1].increment();
});
expect(result.current[0].count).toBe(1);
});Creating a Provider provides an optional hook that can be used to override specific methods while unit testing. Override methods for testing with compile-time safety:
// AsyncCounterStore.ts
// ....
const [AsyncCounterStoreProvider, useCounterStore] = ExternalStore.createProvider(AsyncCounterStore);
export { AsyncCounterStoreProvider, useCounterStore };
// App.tsx
import { useCounterStore } from "./AsyncCounterStore";
export const App = () => {
// `actions` are only the _public_ functions defined on the parent class.
const [state, actions] = useCounter();
return <button onClick={actions.increment}>{state.count}</button>;
};
// App.test.tsx
import { render } from "@testling-library/react";
import { AsyncCounterStoreProvider, AsyncCounterStore } from "./AsyncCounterStore";
import { App } from "./App";
class MockAsyncCounterStore extends AsyncCounterStore {
// TypeScript ensures this matches the original method signature
override async incrementAsync() {
// Remove delay for fast tests
this.setState((prev) => ({
count: prev.count + 1,
loading: false,
}));
}
}
test("async operations work in tests", async () => {
const store = new MockAsyncCounterStore();
const result = render(() => <App />, {
wrapper: ({ children }) => <AbstractProvider store={store}>{children}</AbstractProvider>,
});
// ...
});Zustand has better community support (obviously). I wrote this library because it does not provide great support for async/API test mocking. The Zustand documentation suggests setting up jest-specific mocks, which rely on developers to be more diligent about avoiding contract drift.
This library offers a similar set of benefits to Zustand when comparing to Redux: less boilerplate,
- Less Boilerplate: No actions, reducers, or dispatch patterns
- Direct Method Calls: Call store methods directly instead of dispatching actions
- Built-in Async: Handle async operations without middleware
- Simpler Testing: Test store methods directly without complex setup
Base class for creating stores.
state: T- Current store state (read-only)
setState(update: Partial<T> | (prev: T) => Partial<T>): void- Update stateuse(): [T, Actions<this>]- React hook for full stateuse<U>(selector: (state: T) => U): [U, Actions<this>]- React hook with selectorsubscribe(fn: () => void): () => void- Subscribe to state changes
createProvider<S>(store: S): [Provider, Hook]- Create provider with store instancecreateProvider<S>(name: string): [Provider, Hook]- Create abstract provider
type Actions<S> = {
[K in keyof S as S[K] extends Function ? K : never]: S[K];
};
type State<S> = S extends ExternalStore<infer U> ? U : never;
type Selector<T, U> = (state: T) => U;Contributions are welcome! Please feel free to submit a Pull Request.
MIT License - see the LICENSE file for details.
This library is largely based on Zustand.