Skip to main content
Version: 0.1.2

Why react-logic?

React mixes rendering with business logic. State, side effects, derived values, and dependencies all live in the same function that decides what's on screen. As a feature grows, the function grows. Testing a piece of behavior means rendering a component.

react-logic separates the two:

  • Logic classes — plain TypeScript classes that hold state and behavior. No React APIs inside, no rerenders, no hook rules.
  • The component — calls useLogic(MyLogic), reads signals, returns JSX. That's it.

What you get​

Logic decoupled from React. A logic class is just a class. You can create one without React, unit test it without @testing-library/react, and step through it in a debugger without worrying about hook order.

Real reactivity. State is built on signals. count() reads, count(next) writes. Components automatically re-render when any signal they read changes — no useState chains, no manual selectors, no useMemo boilerplate.

Dependency injection with the right lifetimes. Services live as long as the <Injector> that provides them, not as long as the component that uses them. You can hide and re-show a component without losing its state. Remove a feature and its services go with it.

Adapters where they matter. The DI layer is pluggable. The default adapter ships with the library. The Angular adapter lets you back react-logic with Angular's EnvironmentInjector so you can share Angular services across a React tree.

What it's not​

  • A central store. No global store, no actions, no time-travel devtools. State lives on the logic instance that owns it — local where it should be local, shared via DI where it should be shared. It's still state management, just without the boilerplate.
  • A render optimizer by default. Signals do cut re-renders, and you can opt into more control with batch() or by keeping fine-grained reads inside the component. If you need per-microsecond render budgets, that's still on you.

When to reach for it​

Pick the concept that matches the job:

  • Logic class — for component-local reactive state. When a component has more state and effects than it can carry cleanly, move them onto a logic class and wire it up with useLogic. State, computed values, and methods all live in one place, type-checked together, and React re-renders when the signals you read change.
  • Injectable service — for shared stores. When more than one component (or more than one logic class) needs the same state, lift it into a class and provide it through <Injector>. Anything in that subtree can inject() it. The service outlives individual mounts, so toggling a component off and on doesn't lose its data.
  • Both, together. A common shape: a thin logic class per component for view-specific state, injecting one or more services for shared concerns (auth, cart, current user, etc.).

Testing is the easy part​

Logic classes and services are plain classes that run outside React. The framework ships a small test helper that builds them inside an injection scope, so you can swap any dependency for a fake without touching the class under test or rendering anything:

import { createTestInjectionScope } from '@react-logic/core/testing';

const test = createTestInjectionScope([
{ provide: Api, useValue: { fetchItems: async () => [{ id: 1, price: 10 }] } },
]);
const logic = test.build(CartLogic);

await logic.load();
expect(logic.total()).toBe(10);

test.dispose();

That's it: one provider override, one build() call, no rendering, no renderHook, no provider tree.

Because logic lives on a class instead of inside a component, the state and methods are directly inspectable. You can read logic.total(), assert on logic.cart.items(), call logic.checkout() and check what changed. A React component is opaque by comparison — it returns JSX and that's all you get to see from the outside. Tests against components end up asserting on rendered output as a proxy for state; here you assert on the state itself.

The full testing guide covers async flushing and a few other helpers.

If your component is stateless or only has a little state, plain React is fine.