@mobx-sentinel/core
Version:
MobX library for non-intrusive class-based model enhancement. Acting as a sentinel, it provides change detection, reactive validation, and form integration capabilities without contamination.
387 lines (298 loc) • 19.9 kB
Markdown
# mobx-sentinel
[](https://github.com/creasty/mobx-sentinel/actions/workflows/push.yml)
[](https://codecov.io/gh/creasty/mobx-sentinel)
[](https://opensource.org/licenses/MIT)
> [!CAUTION]
> This library is currently in the early stage of development. User interface is subject to change without notice.
MobX library for non-intrusive class-based model enhancement. Acting as a sentinel, it provides change detection, reactive validation, and form integration capabilities without contamination.
## Motivation
This library originally started with the goal of creating a form-building library centered around models, and evolved into a more general-purpose library that enhances model capabilities with form management being one of its applications.
<details><summary>Read more on form management (English)</summary>
In the projects I'm involved with, we deal with complex domains and promote building view/domain models on the frontend using MobX.<br>
We needed a solution that could work with forms while assuming business logic for how data should be displayed and updated exists as class implementations.<br>
With models as a premise, most responsibilities can and should be placed on the model side.
While there are already many libraries for building forms using MobX, they are all designed from a data serialization perspective rather than modeling,
and have issues either being unable to use models (classes) or not properly separating data and form state management.<br>
Furthermore, from my research, there wasn't a single one designed to allow type-safe implementation from both model and UI ends.<br>
(Check out the [Alternatives](#alternatives) section for more details.)
Given this background, I believe there are two fundamental challenges in implementing forms:
- Form-specific state management: When considering data separately, this only involves submission and validation. For example:
- Disabling the submit button based on submission status or validation errors.
- While validation itself is the model's responsibility, forms need to react to its state changes.
- Input element connection (a.k.a. binding): Properly handling form state and UI events to update forms, models and UI.
- Getting values from the model and writing back on input changes.
- Expressing error states (red borders, displaying messages, etc.)
Additionally, showing error messages to users at appropriate times is important for user experience — _Haven't you been frustrated by UIs that show errors before you've done anything, or immediately display errors while you're still typing?_<br>
Achieving optimal experience requires coordinating multiple UI events and states, and many existing libraries lack proper design and internal implementation.
This library aims to solve these problems through a model-centric design that properly separates and minimizes form responsibilities.
</details>
<details><summary>Read more on form management (Japanese)</summary>
私が関わっているプロジェクトでは複雑なドメインを扱っており、フロントエンドでも MobX を用いてドメインモデルを作り込むことを推進している。<br>
データがどのように表示・更新されるべきかというビジネスロジックがクラス実装として存在する前提で、それをフォームでも使えるようにするソリューションを求めていた。<br>
モデルがある前提では、基本的にモデル側にほとんどの責務を持たせることができるし、そうするべきである。
すでに MobX を活用したフォーム構築のためのライブラリは多く存在しているが、どれもモデリングではなくデータシリアライズの観点で設計されており、
モデル(クラス)を使うことができないか、データとフォームの状態管理の分離が適切にできていないかのいずれかの問題がある。
さらに私の調べた限り、モデルと UI の両方から型安全に実装ができる設計になっているものは1つとして存在しなかった。<br>
(詳細は [Alternatives](#alternatives) セクションを参照)
ここまでの話を踏まえて、フォームを実装する上での本質的な課題は以下の2点であると考える。
- フォーム自体の状態管理: データを分離して考えれば、送信やバリデーションに関わるものだけである。例えば、
- 送信中やエラーがある場合は送信ボタンを押せないように制御する。
- バリデーション自体はモデルの責務であるが、フォームはそれに応じた処理が必要である。
- インプット要素との接続: フォームの状態と UI イベントを適切に処理し、フォームとモデルと UI をそれぞれ更新する。
- モデルから値を取り出し、入力変化があったらモデルに書き込む。
- エラー状態を表現する。(枠を赤くする、メッセージを表示する等)
また、エラーメッセージをユーザに適切なタイミングで表示することはユーザ体験として重要である — _何もしていないのに最初からエラーが表示されていたり、入力途中なのに即座にエラーと表示される UI にイライラしたことはないだろうか?_<br>
最適な体験を実現するためには、複数の UI イベントや状態を組み合わせる必要があり、多くの既存ライブラリは適切な設計・内部実装ができていない。
このライブラリはモデルを中心とした設計で、フォームの責務を適切に分離し最小限にすることで、これらの問題を解決しようとしている。
</details>
## Packages
### `core` — Core functionality like Watcher and Validator
<pre><code>npm install --save <b>-sentinel/core</b></code></pre>
[](https://www.npmjs.com/package/@mobx-sentinel/core)
[](https://bundlephobia.com/package/@mobx-sentinel/core)

- ` ` annotation for tracking nested models.
- ` ` annotation supports objects, boxed observables, arrays, sets, and maps.
- ` .hoist` annotation can be used to hoist sub-fields in a nested model to the parent model.
- `StandardNestedFetcher` (low-level API) provides a simple but powerful mechanism for tracking and retrieving nested models. Allowing other modules (even your own code) to integrate nested models into their logic without hassle.
- `Watcher` detects changes in models automatically.
- All ` ` and ` ` annotations are automatically watched by default.
- ` ` annotation can be used where ` ` is not applicable.<br>
e.g., on private fields: ` #private = observable.box(0)`
- ` .ref` annotation can be used to watch values with identity comparison, in contrast to the default behavior which uses shallow comparison.
- ` ` annotation and `unwatch(() => ...)` function disable change detection when you need to modify values silently.
- `Validator` and `makeValidatable` provides reactive model validation.
- Composable from multiple sources.
- Both sync and async validations are supported.
- Async validations feature smart job scheduling and are cancellable with [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal).
### `form` — Form and bindings
<pre><code>npm install --save <b>-sentinel/form</b></code></pre>
[](https://www.npmjs.com/package/@mobx-sentinel/form)
[](https://bundlephobia.com/package/@mobx-sentinel/form)

- Asynchronous submission
- Composable from multiple sources.
- Cancellable with [AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal).
- Nested and dynamic (array) forms
- Works by mutating models directly.
- Forms are created independently; they don't need to be aware of each other.
- Custom bindings
- Flexible and easy-to-create.
- Most cases can be implemented in less than 50 lines.
- Smart error reporting
- Original validation strategy for a supreme user experience.
### `react` — Standard bindings and hooks for React
<pre><code>npm install --save <b>-sentinel/react</b></code></pre>
[](https://www.npmjs.com/package/@mobx-sentinel/react)
[](https://bundlephobia.com/package/@mobx-sentinel/react)

- React hooks that automatically handle component lifecycle under the hood.
- Standard bindings for most common form elements.
## Design principles
- Model first
- Assumes the existence of class-based models.
- [Form] Pushes responsibilities towards the model side, minimizing form responsibilities.
- [Form] Not intended for simple data-first form implementations.
- Non-intrusive
- Minimizes required interfaces for models, maintaining purity.
- [Form] No direct references between forms and models.
- [Form] Do not manage data directly.
- Transparent I/O
- No module directly mutates models — Makes control obvious and safe.
- [Form] No hidden magic between model ↔ input element interactions.
- Modular implementation
- Multi-package architecture with clear separation of concerns.
- Enhances testability and extensibility.
- Rigorous typing
- Maximizes use of TypeScript's type system for error detection and code completion.
- Improves development productivity.
## Architecture
<details>
- `┈┈` Dashed lines indicate non-reactive relationships.
- `──` Solid lines indicate reactive relationships.
- `━━` Heavy lines indicate main reactive relationships.
Key points:
- Watcher and Validator observe your model, and Form and FormField utilize them.
- Form has no reactive dependencies on FormField/FormBinding.
- State synchronization is only broadcast from Form to FormField (and Watcher).
```mermaid
graph TB
%%subgraph external
%% Object((Object))
%%end
subgraph core package
nested(["@nested"])
StandardNestedFetcher -.-> |retrieves| nested
%%StandardNestedFetcher -.-> |reads| Object
watch(["@watch, @watch.ref, @unwatch"])
Watcher -.-> |retrieves| watch
Watcher -.-> |uses| StandardNestedFetcher
%%Watcher --> |observes| Object
Validator
Validator --> |delegates| AsyncJob["AsyncJob<br>(internal)"]
Validator -.-> |uses| StandardNestedFetcher
%%Validator --> |observes| Object
watch & Watcher & nested & StandardNestedFetcher -.-> |uses| AnnotationProcessor["AnnotationProcessor<br>(internal)"]
end
subgraph form package
Form -.-> |manages/updates| FormField
Form -.-> |manages| FormBinding["<<interface>><br>FormBinding"]
%%FormBinding -.-> |references| Form & FormField
Form ==> Watcher
FormField & Form ==> Validator
Form -.-> |uses| StandardNestedFetcher
Form --> |delegates| Submission["Submission<br>(internal)"]
end
subgraph react package
Hooks --> |updates| Form
Bindings -.-> |implements| FormBinding
Bindings ==> Form & FormField
end
```
</details>
## Overview
[apps/example/](./apps/example) is deployed at [example.mobx-sentinel.creasty.com](https://example.mobx-sentinel.creasty.com).
### Model
```typescript
import { action, observable, makeObservable } from "mobx";
import { nested, makeValidatable } from "@mobx-sentinel/core";
export class Sample {
text: string = "";
number: number | null = null;
date: Date | null = null;
bool: boolean = false;
enum: SampleEnum | null = null;
option: string | null = null;
multiOption: string[] = [];
// Nested/dynamic models can be tracked with @nested annotation
nested = new Other();
array = [new Other()];
constructor() {
makeObservable(this);
// 'Reactive validation' is implemented here
makeValidatable(this, (b) => {
if (this.text === "") b.invalidate("text", "Text is required");
if (this.number === null) b.invalidate("number", "Number is required");
if (this.date === null) b.invalidate("date", "Date is required");
if (this.bool === false) b.invalidate("bool", "Bool must be true");
if (this.enum === null) b.invalidate("enum", "Enum is required");
if (this.option === null) b.invalidate("option", "Option is required");
if (this.multiOption.length === 0) b.invalidate("multiOption", "Multi option is required");
if (this.array.length === 0) b.invalidate("array", "Array is required");
});
}
.bound
addNewArrayItem() {
this.array.push(new Other());
}
}
```
```typescript
const model = new Sample();
// Do something with the model...
model.text = "hello";
model.nested.other = "world";
// Check if the model has changed
const watcher = Watcher.get(model);
watcher.changed //=> true
watcher.changedKeyPaths //=> Set ["text", "nested.other"]
// Check if the model is valid
const validator = Validator.get(model);
validator.isValid //=> false
validator.invalidKeyPaths //=> Set ["number", "date", ..., "array.0.other"]
```
### Form
```tsx
import { observer } from "mobx-react-lite";
import { Form } from "@mobx-sentinel/form";
import { useFormHandler } from "@mobx-sentinel/react";
// Optional: Make custom bind methods available via Form.
//
// Without the extension, use the default bind method with a binding class instead.
// e.g., `.bind(field, InputBinding, config)` → `.bindInput(field, config)`
import "@mobx-sentinel/react/dist/extension";
const SampleForm: React.FC<{ model: Sample }> = observer(({ model }) => {
// Get the form instance for the model.
// You can always get the same form instance for the same model instance.
const form = Form.get(model);
// Form submission logic is implemented here.
useFormHandler(form, "submit", async (abortSignal) => {
// Serialize the model and send it to a server...
return true;
});
return (
<>
<div className="field">
<label {...form.bindLabel(["text", "bool"])}>Text input & Checkbox</label>
<input
{...form.bindInput("text", {
getter: () => model.text, // Get the value from the model.
setter: (v) => (model.text = v), // Write the value to the model.
})}
/>
<ErrorText errors={form.getErrors("text")} />
<input
{...form.bindCheckBox("bool", {
getter: () => model.bool,
setter: (v) => (model.bool = v),
})}
/>
<ErrorText errors={form.getErrors("bool")} />
</div>
...
<div className="field">
<h4>Nested form</h4>
{/* No need to pass the parent form instance to the sub-form. */}
<OtherForm model={model.nested} />
</div>
<div className="field">
<h4>Dynamic form</h4>
<ErrorText errors={form.getErrors("array")} />
{model.array.map((item, i) => (
<OtherForm key={i} model={item} />
))}
{/* Add a new form by mutating the model directly. */}
<button onClick={model.addNewArrayItem}>Add a new form</button>
</div>
<button {...form.bindSubmitButton()}>Submit</button>
</>
);
});
```
```tsx
const OtherForm: React.FC<{ model: Other }> = observer(({ model }) => {
// Forms are completely independent.
// Sub-forms don't need to know its parent form.
const form = Form.get(model);
return (...);
});
```
## Alternatives
Criteria:
[**T**] Type-safe interfaces.
[**B**] Binding for UI.
[**C**] Class-based implementation.
[img-ts]: https://cdn.simpleicons.org/typescript/3178c6?size=16
[img-js]: https://cdn.simpleicons.org/javascript/f7df1e?size=16
[img-adequate]: https://img.shields.io/badge/adequate-_?style=flat&label=test&color=yellowgreen
[img-sparse]: https://img.shields.io/badge/sparse-_?style=flat&label=test&color=red
<!-- prettier-ignore-start -->
| Repository | Stars | Tests | T | B | C |
|------------|-------|-------|---|---|---|
| ![TypeScript][img-ts] [mobx-react-form](https://github.com/foxhound87/mobx-react-form) |  | [](https://codecov.io/gh/foxhound87/mobx-react-form) | | ✓ | |
| ![TypeScript][img-ts] [formstate](https://github.com/formstate/formstate) |  | ![Adequate][img-adequate] | ✓ | | |
| ![TypeScript][img-ts] [formst](https://github.com/formstjs/formst) |  | N/A | | ✓ | |
| ![TypeScript][img-ts] [smashing-form](https://github.com/eyedea-io/smashing-form) |  | ![Sparse][img-sparse] | | ✓ | |
| ![TypeScript][img-ts] [formstate-x](https://github.com/qiniu/formstate-x) |  | [](https://coveralls.io/github/qiniu/formstate-x?branch=master) | ✓ | | |
| ![JavaScript][img-js] [mobx-form-validate](https://github.com/tdzl2003/mobx-form-validate) |  | N/A | | | ✓ |
| ![JavaScript][img-js] [mobx-form](https://github.com/kentik/mobx-form) |  | N/A | | ✓ | |
| ![JavaScript][img-js] [mobx-schema-form](https://github.com/alexhisen/mobx-schema-form) |  | ![Sparse][img-sparse] | | | |
| ![TypeScript][img-ts] [mobx-form-schema](https://github.com/Yoskutik/mobx-form-schema) |  |  | | | ✓ |
| ![JavaScript][img-js] [mobx-form-store](https://github.com/alexhisen/mobx-form-store) |  | ![Adequate][img-adequate] | | | |
| ![TypeScript][img-ts] [mobx-form-reactions](https://github.com/marvinhagemeister/mobx-form-reactions) |  | N/A | | | |
| ...and many more | <10 | | | | | |
<!-- prettier-ignore-end -->
## Milestones
Check out https://github.com/creasty/mobx-sentinel/milestones
## License
MIT