react-state-object
v1.1.1
Published
MobX-friendly React state object lifecycle and hook decorators with class-instance injection.
Downloads
75
Maintainers
Readme
react-state-object
react-state-object is a small library for building
class-based React state that is designed to work
primarily with MobX.
It gives you:
- a base class (
ReactStateObject) with mount/unmount lifecycle hooks - automatic lifecycle propagation through nested child state objects
- a way to use React hooks inside class accessors
(
@fromHook(...)) - a simple class-instance injection system for React trees
- a convenience decorator for injected state accessors
(
@injectInstance(...))
If you are new to MobX, this README teaches the
basics first and then shows how react-state-object
fits on top.
Who This Is For
This library is a good fit when you:
- like class-based state models
- use MobX for observability/computed values/actions
- want React lifecycle ownership (
mount/unmount) for those classes - want to inject shared state instances without wiring dozens of props
This library is not a replacement for MobX. It is a small layer that helps MobX state objects live inside a React app in a structured way.
Install
npm install react-state-object mobx reactYou will usually also want:
npm install mobx-react-litemobx-react-lite is not a peer dependency of this
package, but it is the most common way to make React
components re-render when MobX observables change.
Peer Dependencies
react>=18mobx>=6.0.0 <7
What MobX Does (Beginner-Friendly)
MobX is a state management library built around a simple idea:
- mark values as observable
- derive values with computed getters
- change values in action methods
- make React components observer(...) so they re-render when observables they read change
Tiny MobX example (without react-state-object yet)
import { action, computed, observable } from 'mobx';
import { observer } from 'mobx-react-lite';
class CounterStore {
@observable accessor count = 0;
@computed get doubled(): number {
return this.count * 2;
}
@action increment(): void {
this.count += 1;
}
}
const counter = new CounterStore();
export const Counter = observer(() => {
return (
<div>
<p>Count: {counter.count}</p>
<p>Doubled: {counter.doubled}</p>
<button onClick={() => counter.increment()}>
Increment
</button>
</div>
);
});This works, but it leaves open questions:
- When should the store set up subscriptions?
- When should it clean them up?
- How do we structure nested state objects?
- How do we use React hooks in class state?
- How do we avoid prop-drilling shared state?
That is where react-state-object helps.
Mental Model: MobX + react-state-object
Think of a ReactStateObject as:
- a MobX-powered class state model
- whose lifecycle is controlled by a React component
- and which may contain child state objects that mount / unmount explicitly when marked
Typical flow:
- React component creates the class via
useMountStateObject(MyState) - React owns the instance lifetime
- The state object receives
mount()on component mount - The state object receives
unmount()on component unmount - Marked child
ReactStateObjects are mounted/unmounted recursively
This pattern is common in production apps for app layout state, environment observers (window size / element size), page-level state, and feature-specific state trees.
Quick Start (Recommended First Example)
This example shows the full intended pattern with MobX.
import {
action,
autorun,
computed,
observable,
} from 'mobx';
import { observer } from 'mobx-react-lite';
import {
ReactStateObject,
useMountStateObject,
} from 'react-state-object';
class CounterState extends ReactStateObject {
@observable accessor count = 0;
@computed get doubled(): number {
return this.count * 2;
}
protected mount(): void {
// Runs once when the React component mounts.
this.withCleanup(() => {
// `autorun` returns a disposer. `withCleanup` stores it
// and calls it automatically on unmount.
return autorun(() => {
console.log('count is', this.count);
});
});
}
@action increment(): void {
this.count += 1;
}
@action decrement(): void {
this.count -= 1;
}
}
export const CounterScreen = observer((): JSX.Element => {
const state = useMountStateObject(CounterState);
return (
<div>
<p>Count: {state.count}</p>
<p>Doubled: {state.doubled}</p>
<button onClick={() => state.decrement()}>-</button>
<button onClick={() => state.increment()}>+</button>
</div>
);
});Core Concepts
ReactStateObject
Base class for state objects that need lifecycle and child-state propagation.
class MyState extends ReactStateObject {
protected mount(): void {
// setup subscriptions, observers, timers, etc.
}
protected unmount(): void {
// optional manual cleanup if needed
}
}What it adds on top of a plain MobX class
mount()andunmount()hooks- explicit child state object registration and recursive mount/unmount
withCleanup(...)helper to register disposer functionshookIntoLifecycle(...)for advanced lifecycle registration
Explicit child lifecycle propagation
In larger apps, state objects often contain smaller ones (for example, a layout state containing an element-size observer, a sidebar state object, and other focused child state objects).
Mark child state objects with @mountStateObject when
the parent owns their lifecycle. Unmarked references are
ignored by lifecycle traversal.
class FiltersState extends ReactStateObject {
@observable accessor query = '';
}
class TableState extends ReactStateObject {
@mountStateObject
@observable accessor filters = new FiltersState();
}
// Mounting `TableState` also mounts `filters`.This makes it easy to create a tree of state objects without manually coordinating lifecycle for every child.
useMountStateObject(StateObjectClass, ...constructorArgs)
This is the React hook that creates and owns a
ReactStateObject instance.
const state = useMountStateObject(MyState);const state = useMountStateObject(
UserState,
userId
);const state = useMountStateObject(
UserState,
() => new UserState(userId),
[userId]
);What it does
- creates the instance once per component instance while the class identity and tracked dependencies stay the same
- always treats
StateObjectClassas a dependency, so a changed class identity recreates the instance - treats constructor arguments as dependencies in the
useMountStateObject(StateObjectClass, ...args)form - records any React hooks used by
@fromHook(...)decorators during initialization - replays those hooks on subsequent renders to preserve hook order
- calls the state object lifecycle (
mount/unmount) - recreates the state object when
dependencieschange and reruns its lifecycle
Important rule (always)
A ReactStateObject should never be initialized
outside of useMountStateObject(...) when used from
React.
Always create it with:
const state = useMountStateObject(MyState);Do not create a ReactStateObject with plain new
inside a React component (including useMemo,
useRef, module-level singletons used as UI state, or
conditional branches).
Lifecycle Helpers
withCleanup(...) (most common)
This is the main lifecycle helper you will use.
It runs setup logic now and stores the returned cleanup function for unmount.
protected mount(): void {
this.withCleanup(() => {
const id = window.setInterval(() => {
console.log('tick');
}, 1000);
return () => {
window.clearInterval(id);
};
});
}This pattern is commonly used for:
autorun(...)disposers- DOM event listeners (
resize, etc.) - subscriptions / observables
hookIntoLifecycle(...) (advanced)
This is a lower-level API that lets you register mount / unmount callbacks directly.
constructor() {
super();
this.hookIntoLifecycle({
onMount: () => {
console.log('mounted');
},
onUnmount: () => {
console.log('unmounted');
},
});
}In most apps, withCleanup(...) is the primary
pattern; hookIntoLifecycle(...) exists for more
custom composition scenarios.
MobX + React Rendering (How UI Updates)
react-state-object does not automatically make your
component reactive. React components still need to be
wrapped with MobX's observer(...) (or use another MobX
React integration pattern).
import { observer } from 'mobx-react-lite';
const UserPanel = observer(() => {
const state = useMountStateObject(UserState);
// Reading observables here makes this component react.
return <div>{state.userName}</div>;
});If you read MobX observables in a component that is not
an observer, React usually will not re-render when they
change.
Using React Hooks Inside a Class (@fromHook(...))
A major feature of this library is the ability to bind a class accessor to a React hook result.
This is useful when your state object needs data from a React hook such as:
- routing hooks (
useParams,useLocation,usePage) - context hooks
- feature hooks that return callbacks/services
Basic example
import { observable } from 'mobx';
import { useLocation } from 'react-router-dom';
import {
fromHook,
ReactStateObject,
} from 'react-state-object';
class RouteState extends ReactStateObject {
@fromHook(() => useLocation())
@observable
accessor location!: ReturnType<typeof useLocation>;
}Example with this access (common app pattern)
@fromHook(...) can receive a function that uses the
state object instance as this.
This is useful when the hook needs the state object.
class ModalState extends ReactStateObject {
@fromHook(function (this: ModalState) {
return useModalLauncher(this);
})
@observable
accessor openModal!: () => void;
}This pattern is useful when a feature state object binds a hook-derived callback or service that needs access to the state instance.
Rules for @fromHook(...)
- Use it on
accessorproperties. - Create the state object with
useMountStateObject(...). - Keep hook usage stable (same hooks in same order across renders), just like normal React rules.
- Typically combine it with
@observablewhen you want MobX reactivity on the accessor value.
Why injection and hook-backed values are not children by default
@fromHook(...) and @injectInstance(...) do not
participate in child lifecycle traversal unless they are
also decorated with @mountStateObject.
This keeps hook-managed and injected values under their own ownership model unless you explicitly declare parent ownership.
Class Instance Injection (DI) for React Trees
This library includes a simple class-instance injection system. It lets you bind an instance in React and then retrieve it later by class.
This is a strong fit for sharing app-level and page-level state (for example layout state, route/page observers, window-size observers, and feature state) without prop drilling.
InstanceInjectionRoot
This provider stores the internal registry of class -> React context mappings.
Put it near the top of your app (once).
<InstanceInjectionRoot>
<App />
</InstanceInjectionRoot>BindInstanceForInjection
Binds a specific class instance for descendants.
<BindInstanceForInjection instance={appState}>
<Children />
</BindInstanceForInjection>You can nest these to provide multiple state objects.
<InstanceInjectionRoot>
<BindInstanceForInjection instance={appState}>
<BindInstanceForInjection instance={sessionState}>
<MyScreen />
</BindInstanceForInjection>
</BindInstanceForInjection>
</InstanceInjectionRoot>useInjectInstance(MyClass)
Reads the nearest instance bound for a class. Throws if missing.
import { observer } from 'mobx-react-lite';
import { useInjectInstance } from 'react-state-object';
const Header = observer(() => {
const appState = useInjectInstance(AppState);
return <h1>{appState.title}</h1>;
});useInjectInstanceOrNull(MyClass)
Same as useInjectInstance(...), but returns null
instead of throwing if no bound instance exists on the
current branch.
const maybeAppState = useInjectInstanceOrNull(AppState);@injectInstance(...) (Decorator)
@injectInstance(...) is a convenience decorator built on
@fromHook(...). It injects an instance into a class
accessor.
This lets one ReactStateObject depend on another
without manually calling hooks in component code.
import { observable } from 'mobx';
import {
injectInstance,
ReactStateObject,
} from 'react-state-object';
class AppState extends ReactStateObject {
@observable accessor title = 'Dashboard';
}
class PageState extends ReactStateObject {
@injectInstance(AppState)
@observable
accessor appState!: AppState;
}This is a common pattern in larger apps, where feature state objects inject app-level or page observer state objects.
End-to-End Example: App State + Page State + UI
This example combines MobX, useMountStateObject,
injection, and observer(...).
import { action, computed, observable } from 'mobx';
import { observer } from 'mobx-react-lite';
import {
BindInstanceForInjection,
injectInstance,
InstanceInjectionRoot,
ReactStateObject,
useInjectInstance,
useMountStateObject,
} from 'react-state-object';
class AppState extends ReactStateObject {
@observable accessor appName = 'School Portal';
}
class CounterPageState extends ReactStateObject {
@injectInstance(AppState)
@observable
accessor appState!: AppState;
@observable accessor count = 0;
@computed get title(): string {
return `${this.appState.appName} (${this.count})`;
}
@action increment(): void {
this.count += 1;
}
}
const CounterPageBody = observer(() => {
const pageState = useInjectInstance(CounterPageState);
return (
<div>
<h2>{pageState.title}</h2>
<button onClick={() => pageState.increment()}>
Increment
</button>
</div>
);
});
const CounterPage = observer(() => {
const pageState =
useMountStateObject(CounterPageState);
return (
<BindInstanceForInjection instance={pageState}>
<CounterPageBody />
</BindInstanceForInjection>
);
});
export const App = () => {
const appState = useMountStateObject(AppState);
return (
<InstanceInjectionRoot>
<BindInstanceForInjection instance={appState}>
<CounterPage />
</BindInstanceForInjection>
</InstanceInjectionRoot>
);
};Example: DOM Observer State (ResizeObserver pattern)
A common pattern is using a state object to wrap browser
APIs like ResizeObserver.
import { action, observable } from 'mobx';
import {
ReactStateObject,
useMountStateObject,
} from 'react-state-object';
import { observer } from 'mobx-react-lite';
class ElementSizeState extends ReactStateObject {
@observable accessor width = 0;
@observable accessor height = 0;
private readonly resizeObserver = new ResizeObserver(
([entry]) => {
if (!entry) return;
this.setSize(
entry.contentRect.width,
entry.contentRect.height
);
}
);
readonly elementRef = (
el: HTMLDivElement | null
): void => {
this.resizeObserver.disconnect();
if (el) this.resizeObserver.observe(el);
};
protected unmount(): void {
this.resizeObserver.disconnect();
}
@action private setSize(
width: number,
height: number
): void {
this.width = width;
this.height = height;
}
}
export const MeasuredPanel = observer(() => {
const size = useMountStateObject(ElementSizeState);
return (
<div>
<div ref={size.elementRef}>Resize me</div>
<p>
{size.width} x {size.height}
</p>
</div>
);
});This pattern keeps browser API setup/cleanup inside a state class instead of scattering it across components.
Example: Using a Hook-Derived Service in State
This mirrors a common production pattern where a state object gets a hook-derived function (for example a toast helper) and calls it inside an action.
import { action, observable } from 'mobx';
import {
fromHook,
ReactStateObject,
} from 'react-state-object';
function useNotifier(): {
success: (msg: string) => void;
} {
return {
success: (msg) => console.log('SUCCESS:', msg),
};
}
class SaveState extends ReactStateObject {
@observable accessor isSaving = false;
@fromHook(() => useNotifier())
@observable
accessor notifier!: ReturnType<typeof useNotifier>;
@action async save(): Promise<void> {
this.isSaving = true;
try {
await new Promise((resolve) =>
setTimeout(resolve, 300)
);
this.notifier.success('Saved successfully');
} finally {
this.isSaving = false;
}
}
}How This Is Commonly Used in Larger Apps (Pattern Summary)
Based on common production usage patterns, a common structure is:
App-level state objects
- created near app root with
useMountStateObject(...) - bound with
BindInstanceForInjection - examples: theme state, page observer state, window size observer
- created near app root with
Page-level state objects
- created in page/layout components
- may inject app-level state via
@injectInstance(...) - may compose nested child state objects with
@mountStateObject
Feature sub-state objects
- nested inside page state (forms, sidebar state, tables, save state, UI state)
- mounted/unmounted explicitly through parent
ReactStateObject
React components
- wrapped in
observer(...) - either read state directly via props or retrieve it
via
useInjectInstance(...)
- wrapped in
This architecture keeps React components focused on UI while moving orchestration/subscriptions into state classes.
Important: every ReactStateObject in this structure
should still be created through useMountStateObject(...)
at the React boundary that owns it. Child state objects
can be instantiated inside parent state object
constructors because their lifecycle is then managed by
the parent ReactStateObject created via
useMountStateObject(...).
API Reference
ReactStateObject
Base class with lifecycle and child-state propagation.
Methods you typically override:
protected mount(): voidprotected unmount(): void
Helpers:
protected withCleanup(action: () => () => void): voidprotected hookIntoLifecycle({ onMount?, onUnmount? })
useMountStateObject(StateObjectClass, ...constructorArgs)
Creates a ReactStateObject and ties it to React
component lifecycle. The class identity is always a
dependency. In the constructor-argument form, those
arguments are dependencies automatically. In the custom
factory form, the explicit dependency array is used in
addition to the class identity.
const state = useMountStateObject(MyState);const state = useMountStateObject(
UserState,
userId
);const state = useMountStateObject(
UserState,
() => new UserState(userId),
[userId]
);This is the required creation path for ReactStateObject
instances used by React components.
fromHook(hookFn)
Accessor decorator that assigns a React hook result to a state object accessor.
@fromHook(() => useSomeHook())
@observable
accessor value!: ReturnType<typeof useSomeHook>;injectInstance(Class)
Accessor decorator that injects a class instance from the
injection tree using useInjectInstance(...).
@injectInstance(AppState)
@observable
accessor appState!: AppState;invokeReactStateObjectHook(hook)
Low-level hook registration helper used internally by
fromHook(...). Most users should not call this
manually.
InstanceInjectionRoot
Root provider for the class-instance injection registry.
BindInstanceForInjection
Binds an instance for descendants.
useInjectInstance(Class)
Gets the nearest instance for a class or throws.
useInjectInstanceOrNull(Class)
Gets the nearest instance for a class or returns null.
Common Mistakes (and Fixes)
1) Component does not re-render when state changes
Cause:
- the component is not wrapped in
observer(...)
Fix:
- wrap the component with
observerfrommobx-react-lite
2) fromHook(...) throws an error about
useMountStateObject
Cause:
- you created a
ReactStateObjectwithnew MyState()directly in a component instead of usinguseMountStateObject(...)
Fix:
- create it with
useMountStateObject(...)
3) useInjectInstance(...) throws that no instance was
bound
Cause:
- missing
InstanceInjectionRoot - missing
BindInstanceForInjection - bound instance is on a different branch of the tree
Fix:
- add
InstanceInjectionRootnear the app root - ensure the consuming component is a descendant of the
matching
BindInstanceForInjection
4) Hook order errors in fromHook(...)
Cause:
- conditional hook usage inside
@fromHook(...)
Fix:
- keep hook usage unconditional and stable, same as normal React hook rules
Publishing / Development
Scripts
npm run formatnpm run lintnpm run typechecknpm run build
Prettier config
This package copies the author's existing Prettier setup style, including:
.prettierrc.prettierignoreprettier-plugin-organize-imports- matching
formatandlint-stagedscripts
License
UNLICENSED (update before publishing publicly if
needed).
