# mobx-toolbox

A collection of MobX + React utilities: lazy-loading observables, model/store factories, a client-side router, form state management, dialog management, a headless virtualized table, reactive value filters, and general-purpose React hooks.

## Installation

```sh
pnpm add @jayalfredprufrock/mobx-toolbox mobx mobx-react-lite
```

Peer dependencies are per-module — see the **Requires** column below. `typebox` and `history` are declared optional, so they are only pulled in if you reach for `form`/`model` or `router`.

---

## Modules

Each module is its own entry point — import from `@jayalfredprufrock/mobx-toolbox/<module>` so you only pull in what you use.

| Module                                | Summary                                                                       | Requires                                      |
| ------------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------- |
| [`dialog`](#dialog)                   | Dialog/modal stack with state transitions for enter/exit animations           | `mobx`, `mobx-react-lite`, `react`            |
| [`filter`](#filter)                   | Reactive value filters — set, number, date, bucket and text                   | `mobx`                                        |
| [`form`](#form)                       | Schema-driven form state with TypeBox validation and submit lifecycle         | `mobx`, `mobx-react-lite`, `react`, `typebox` |
| [`lazy-observable`](#lazy-observable) | Observables that fetch on first observation and reset when unobserved         | `mobx`, `mobx-react-lite`, `react`            |
| [`model`](#model)                     | Observable model classes and collection stores from TypeBox schemas           | `mobx`, `react`, `typebox`                    |
| [`react-util`](#react-util)           | General-purpose React hooks: async state, debouncing, resize, mount lifecycle | `react`                                       |
| [`router`](#router)                   | Client-side router with symbol-keyed guards, loaders and layouts              | `mobx`, `mobx-react-lite`, `react`, `history` |
| [`table`](#table)                     | Headless, virtualized data table                                              | `mobx`, `mobx-react-lite`, `react`            |
| [`uploader`](#uploader)               | Headless multipart upload engine with progress, retries and a form value      | `mobx`, `mobx-react-lite`, `react`            |
| [`util`](#util)                       | Small MobX + React utilities — `mutable`, `useAutorun`, `useObservableBox`    | `mobx`, `react`                               |

### [`dialog`](src/dialog/README.md)

MobX-powered dialog/modal stack with state-transition support for enter/exit animations.

```tsx
import { useDialogs, MobxDialogs } from "@jayalfredprufrock/mobx-toolbox/dialog";

function App() {
  const dialogs = useDialogs();
  return <MobxDialogs store={dialogs} />;
}

// Anywhere in the tree:
const dialogs = useDialogStore();
dialogs.open(ConfirmModal, { message: "Are you sure?" });
```

→ [Full docs](src/dialog/README.md)

---

### [`filter`](src/filter/README.md)

Reactive value filters. A filter is a predicate over one already-extracted **value**, not over a row, so the same instance works over an array, a sidebar rail, or a table column that feeds it the column's own accessor.

```ts
import { SetFilter, NumberFilter, BucketFilter } from "@jayalfredprufrock/mobx-toolbox/filter";

const status = new SetFilter({ options: ["open", "closed"] });
status.toggle("open");

tickets.filter((t) => status.matches(t.status));
```

`NumberFilter` carries the usual operators, `DateFilter` absorbs `Date`s / epoch numbers / ISO strings, and `BucketFilter` filters a numeric column by named range ("B") while the column goes on showing and sorting the raw value.

Missing and empty values normalise to one `BLANK` sentinel — shared by `matches` and by facet tallies, so a "(Blank)" option can never list a value that selects nothing.

→ [Full docs](src/filter/README.md)

### [`form`](src/form/README.md)

Schema-driven form state with TypeBox validation, field-level error messages, and submit lifecycle tracking.

```tsx
import { useForm, MobxForm } from "@jayalfredprufrock/mobx-toolbox/form";
import * as T from "typebox";

const schema = T.Object({ email: T.String(), password: T.String({ minLength: 8 }) });

function LoginForm() {
  const form = useForm(schema, { handleSubmit: async (data) => login(data) });
  return (
    <MobxForm store={form}>
      <input {...form.fields.email.props()} />
      <button type="submit">Login</button>
    </MobxForm>
  );
}
```

→ [Full docs](src/form/README.md)

---

### [`lazy-observable`](src/lazy-observable/README.md)

Lazy-loading MobX observables that fetch data on first observation and reset automatically when unobserved.

```ts
import { lazyObservableArray } from "@jayalfredprufrock/mobx-toolbox/lazy-observable";

const users = lazyObservableArray(() => api.getUsers());
// users.value is [] until observed; fetch fires automatically inside an observer
```

Includes a `LazyObserver` React component that renders a placeholder while loading and propagates fetch errors to an error boundary.

→ [Full docs](src/lazy-observable/README.md)

---

### [`model`](src/model/README.md)

Factory functions for creating observable model classes and collection stores from TypeBox schemas.

```ts
import { createStore, makeModel } from "@jayalfredprufrock/mobx-toolbox/model";
import * as T from "typebox";

const UserModel = makeModel(T.Object({ id: T.Number(), name: T.String() }), {
  keys: ["id"], // identifies a record — `[]` for a singleton, `false` for no identity
  get: ({ id }) => api.get(`/users/${id}`), // → UserModel.get({ id }), and derives reload()
  create: (body) => api.post("/users", body),
  delete: ({ id }) => api.delete(`/users/${id}`),
});

export const users = createStore(UserModel, {
  sort: (a, b) => a.name.localeCompare(b.name),
  collections: {
    all: (options) => api.get("/users", options),
  },
});

await users.all.getOrLoad(); // → User[], loaded once and cached
await users.all.value[0].delete(); // removed from every list over this model
```

Lists whose parameters aren't known up front are keyed on the store, or scoped to a component:

```tsx
class Users extends makeStore(UserModel) {
  byOrg = this.collectionMap(["orgId"], ({ orgId }, options) =>
    api.get("/users", { orgId, ...options }),
  );
}

// ...or, for parameters that are a component's own React state:
const list = useCollection(UserModel, ({ q }, options) => api.get("/users", { q, ...options }), {
  params: { q: query },
});
```

→ [Full docs](src/model/README.md)

---

### [`react-util`](src/react-util/README.md)

General-purpose React hooks: async state management, debouncing, resize observation, and mount lifecycle helpers.

```ts
import { useAsync, useDebouncedCallback } from "@jayalfredprufrock/mobx-toolbox/react-util";

// Runs on mount; re-runs when deps change; handles loading/error/value
const state = useAsync(async (signal) => fetchUser(id), [id]);

// Stable debounced callback, safe to call after unmount
const save = useDebouncedCallback((value) => persist(value), []);
```

→ [Full docs](src/react-util/README.md)

---

### [`router`](src/router/README.md)

MobX-based client-side router for React. Routes are plain objects; symbol-keyed metadata controls guards, loaders, and layouts.

```tsx
import {
  RouterStore,
  Router,
  makeRoutes,
  GUARD,
  LOAD,
} from "@jayalfredprufrock/mobx-toolbox/router";

const routes = makeRoutes()({
  index: HomePage,
  dashboard: {
    [GUARD]: requireAuth,
    [LOAD]: loadDashboard,
    index: DashboardPage,
    $id: DetailPage,
  },
});

const router = new RouterStore();
router.initialize(routes);

function App() {
  return <Router store={router} />;
}
```

→ [Full docs](src/router/README.md)

---

### [`table`](src/table/README.md)

Headless, virtualized data table. The model owns columns, widths, sorting, selection, expansion and the render window; the components own only structure and ARIA.

```tsx
import { useTable, Table } from "@jayalfredprufrock/mobx-toolbox/table";

function UserTable({ users }) {
  const table = useTable({ rows: users, columns: ["name", "email", { selection: true }] });

  return (
    <Table.Root table={table}>
      <Table.Header>
        {(column) => <Table.ColumnHeader column={column}>{column.title}</Table.ColumnHeader>}
      </Table.Header>
      <Table.Body>
        {(row) => (
          <Table.Row row={row}>
            {(column) => <Table.Cell column={column}>{String(column.getValue(row))}</Table.Cell>}
          </Table.Row>
        )}
      </Table.Body>
    </Table.Root>
  );
}
```

→ [Full docs](src/table/README.md)

---

### [`uploader`](src/uploader/README.md)

Headless multipart upload engine. The model owns part slicing, concurrency, progress, retries, cancellation and the completed-upload form value; you own the selection UI. Drives equally well from a bare `<input>` or from Chakra/Ark's `FileUpload`.

```tsx
import { useUploader, Uploader } from "@jayalfredprufrock/mobx-toolbox/uploader";

function DocumentUpload({ value, onChange }) {
  const uploader = useUploader({
    accept: ".pdf",
    value,
    onChange,
    requestUpload: async (signal, file) => {
      const res = await api.requestUpload({ fileName: file.name, size: file.size }, { signal });
      // part sizes come from the server — never derived client-side
      return { id: res.id, name: res.fileName, parts: res.parts };
    },
  });

  return (
    <Uploader.Root uploader={uploader}>
      <ul>
        <Uploader.Uploads>
          {(upload) => (
            <li>
              {upload.name} — {upload.status} {upload.progress}%
            </li>
          )}
        </Uploader.Uploads>
      </ul>
    </Uploader.Root>
  );
}
```

→ [Full docs](src/uploader/README.md)

---

### [`util`](src/util/README.md)

Small MobX + React utilities.

```tsx
import { mutable, useAutorun, useObservableBox } from "@jayalfredprufrock/mobx-toolbox/util";

// Class accessor decorator — makes a field reactive without makeObservable
class Store {
  @mutable accessor theme = "light";
}

function MyComponent({ orgId }: { orgId: string }) {
  // MobX autorun that disposes on unmount
  useAutorun(() => {
    document.title = store.pageTitle;
  });

  // A React value MobX can watch — for a reaction, a computed, or trackDependencies
  const params = useObservableBox({ orgId });
}
```

→ [Full docs](src/util/README.md)
