An open API service indexing awesome lists of open source software.

https://github.com/patrickjames242/react-state-object

A small library for building class-based React state that is designed to work primarily with MobX.
https://github.com/patrickjames242/react-state-object

Last synced: 14 days ago
JSON representation

A small library for building class-based React state that is designed to work primarily with MobX.

Awesome Lists containing this project

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

```bash
npm install react-state-object mobx react
```

You will usually also want:

```bash
npm install mobx-react-lite
```

`mobx-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` `>=18`
- `mobx` `>=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)

```tsx
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 (


Count: {counter.count}


Doubled: {counter.doubled}


counter.increment()}>
Increment


);
});
```

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:

1. React component creates the class via
`useMountStateObject(MyState)`
2. React owns the instance lifetime
3. The state object receives `mount()` on component mount
4. The state object receives `unmount()` on component
unmount
5. Marked child `ReactStateObject`s 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.

```tsx
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 (


Count: {state.count}


Doubled: {state.doubled}


state.decrement()}>-
state.increment()}>+

);
});
```

## Core Concepts

## `ReactStateObject`

Base class for state objects that need lifecycle and
child-state propagation.

```ts
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()` and `unmount()` hooks
- explicit child state object registration and recursive
mount/unmount
- `withCleanup(...)` helper to register disposer
functions
- `hookIntoLifecycle(...)` 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.

```ts
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.

```ts
const state = useMountStateObject(MyState);
```

```ts
const state = useMountStateObject(
UserState,
userId
);
```

```ts
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 `StateObjectClass` as 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 `dependencies` change
and reruns its lifecycle

### Important rule (always)

A `ReactStateObject` should **never** be initialized
outside of `useMountStateObject(...)` when used from
React.

Always create it with:

```ts
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.

```ts
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.

```ts
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).

```tsx
import { observer } from 'mobx-react-lite';

const UserPanel = observer(() => {
const state = useMountStateObject(UserState);

// Reading observables here makes this component react.
return

{state.userName}
;
});
```

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

```tsx
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;
}
```

### 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.

```tsx
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 `accessor` properties.
- 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 `@observable` when 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).

```tsx

```

## `BindInstanceForInjection`

Binds a specific class instance for descendants.

```tsx

```

You can nest these to provide multiple state objects.

```tsx





```

## `useInjectInstance(MyClass)`

Reads the nearest instance bound for a class.
Throws if missing.

```tsx
import { observer } from 'mobx-react-lite';
import { useInjectInstance } from 'react-state-object';

const Header = observer(() => {
const appState = useInjectInstance(AppState);

return

{appState.title}

;
});
```

## `useInjectInstanceOrNull(MyClass)`

Same as `useInjectInstance(...)`, but returns `null`
instead of throwing if no bound instance exists on the
current branch.

```tsx
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.

```tsx
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(...)`.

```tsx
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 (


{pageState.title}


pageState.increment()}>
Increment


);
});

const CounterPage = observer(() => {
const pageState =
useMountStateObject(CounterPageState);

return (



);
});

export const App = () => {
const appState = useMountStateObject(AppState);

return (





);
};
```

## Example: DOM Observer State (ResizeObserver pattern)

A common pattern is using a state object to wrap browser
APIs like `ResizeObserver`.

```tsx
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 (


Resize me


{size.width} x {size.height}



);
});
```

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.

```tsx
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;

@action async save(): Promise {
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:

1. **App-level state objects**
- created near app root with `useMountStateObject(...)`
- bound with `BindInstanceForInjection`
- examples: theme state, page observer state, window
size observer

2. **Page-level state objects**
- created in page/layout components
- may inject app-level state via `@injectInstance(...)`
- may compose nested child state objects with
`@mountStateObject`

3. **Feature sub-state objects**
- nested inside page state (forms, sidebar state,
tables, save state, UI state)
- mounted/unmounted explicitly through parent
`ReactStateObject`

4. **React components**
- wrapped in `observer(...)`
- either read state directly via props or retrieve it
via `useInjectInstance(...)`

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(): void`
- `protected unmount(): void`

Helpers:

- `protected withCleanup(action: () => () => void): void`
- `protected 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.

```ts
const state = useMountStateObject(MyState);
```

```ts
const state = useMountStateObject(
UserState,
userId
);
```

```ts
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.

```ts
@fromHook(() => useSomeHook())
@observable
accessor value!: ReturnType;
```

## `injectInstance(Class)`

Accessor decorator that injects a class instance from the
injection tree using `useInjectInstance(...)`.

```ts
@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 `observer` from
`mobx-react-lite`

## 2) `fromHook(...)` throws an error about

`useMountStateObject`

Cause:

- you created a `ReactStateObject` with `new MyState()`
directly in a component instead of using
`useMountStateObject(...)`

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 `InstanceInjectionRoot` near 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 format`
- `npm run lint`
- `npm run typecheck`
- `npm run build`

### Prettier config

This package copies the author's existing Prettier setup
style,
including:

- `.prettierrc`
- `.prettierignore`
- `prettier-plugin-organize-imports`
- matching `format` and `lint-staged` scripts

## License

`UNLICENSED` (update before publishing publicly if
needed).