@mobx-sentinel/react
TypeScript icon, indicating that this package has built-in type declarations

0.2.3 • Public • Published

mobx-sentinel

push codecov License: MIT

[!CAUTION] This library is currently in the early stage of development. User interface is subject to change without notice.

A TypeScript library for non-intrusive model enhancement in MobX applications. It provides model change detection, validation, and form integration capabilities while maintaining the purity of your domain models. Think of it as a sentinel that watches and augments your models without contaminating them.

Motivation

TL;DR: This library provides non-intrusive change detection and validation capabilities for domain models, with form management being one of its applications. Unlike other libraries that focus on data serialization or require models to implement specific interfaces, it's designed to work with plain classes while properly separating concerns.

Read more on form management (English)

In the projects I'm involved with, we deal with complex domains and promote building domain models on the frontend using MobX.
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.
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.
Furthermore, from my research, there wasn't a single one designed to allow type-safe implementation from both model and UI ends.
(Check out the 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 processing. For example:
    • Disabling the submit button based on submission status or validation errors.
    • Running validations in response to form updates.
  • 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?
Achieving optimal experience requires coordinating multiple UI events (change, focus, blur) and states.
While this is something we'd prefer to delegate to a library since it's complex to write manually, it's a major factor complicating implementation on both "form state management" and "input element connection" fronts, and many existing libraries haven't achieved proper design.

This library aims to solve these problems through a model-centric design that properly separates and minimizes form responsibilities.

Read more on form management (Japanese)

私が関わっているプロジェクトでは複雑なドメインを扱っており、フロントエンドでも MobX を用いてドメインモデルを作り込むことを推進している。
データがどのように表示・更新されるべきかというビジネスロジックがクラス実装として存在する前提で、それをフォームでも使えるようにするソリューションを求めていた。
モデルがある前提では、基本的にモデル側にほとんどの責務を持たせることができるし、そうするべきである。

すでに MobX を活用したフォーム構築のためのライブラリは多く存在しているが、どれもモデリングではなくデータシリアライズの観点で設計されており、 モデル(クラス)を使うことができないか、データとフォームの状態管理の分離が適切にできていないかのいずれかの問題がある。 さらに私の調べた限り、モデルと UI の両方から型安全に実装ができる設計になっているものは1つとして存在しなかった。
(詳細は Alternatives セクションを参照)

ここまでの話を踏まえて、フォームを実装する上での本質的な課題は以下の2点であると考える。

  • フォーム自体の状態管理: データを分離して考えれば、送信やバリデーション処理に関わるものだけである。例えば、
    • 送信中やエラーがある場合は送信ボタンを押せないように制御する。
    • フォームの更新に応じてバリデーションを走らせる。
  • インプット要素との接続: フォームの状態と UI イベントを適切に処理し、フォームとモデルと UI をそれぞれ更新する。
    • モデルから値を取り出し、入力変化があったらモデルに書き込む。
    • エラー状態を表現する。(枠を赤くする、メッセージを表示する等)

また、エラーメッセージをユーザに適切なタイミングで表示することはユーザ体験として重要である — 何もしていないのに最初からエラーが表示されていたり、入力途中なのに即座にエラーと表示される UI にイライラしたことはないだろうか?
最適な体験を実現するためには、複数の UI イベント (change, focus, blur) や状態を組み合わせる必要がある。
これを手で書くのは大変なためライブラリに任せたいところだが、「フォーム自体の状態管理」と「インプット要素との接続」の両側面で実装が複雑になる主な要因であると考えており、多くの既存ライブラリは適切な設計ができていない。

このライブラリはモデルを中心とした設計で、フォームの責務を適切に分離し最小限にすることで、これらの問題を解決しようとしている。

Packages

core — Core functionality like Watcher and Validator

npm install --save @mobx-sentinel/core

npm version npm size target: nodejs, browser

  • @nested annotation for tracking nested models.
    • @nested annotation supports objects, boxed observables, arrays, sets, and maps.
    • @nested.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 @observable and @computed annotations are automatically watched by default.
    • @watch annotation can be used where @observable is not applicable.
      e.g., on private fields: @watch #private = observable.box(0)
    • @watch.ref annotation can be used to watch values with identity comparison, in contrast to the default behavior which uses shallow comparison.
    • @unwatch 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.

form — Form and bindings

npm install --save @mobx-sentinel/form

npm version npm size target: nodejs, browser

  • Asynchronous submission
    • Composable from multiple sources.
    • Cancellable with 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

npm install --save @mobx-sentinel/react

npm version npm size target: browser

  • 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 domain models
    • Pushes responsibilities towards the model side, minimizing form responsibilities
    • Not intended for simple data-first form implementations
  • Separation of form state and model
    • No direct references between forms and models
    • Models remain uncontaminated by form logic
    • Forms don't manage data directly
  • Transparent I/O
    • No hidden magic between model ↔ input element interactions
    • Makes control obvious and safe
  • Modular implementation
    • Multi-package architecture with clear separation between behavior model and UI
    • Enhances testability and extensibility
  • Rigorous typing
    • Maximizes use of TypeScript's type system for error detection and code completion
    • Improves development productivity

Architecture

  • ┈┈ 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).
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["&lt;&lt;interface&gt;&gt;<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

Overview

apps/example/ is deployed at example.mobx-sentinel.creasty.com.

Model

import { action, observable, makeObservable } from "mobx";
import { nested, makeValidatable } from "@mobx-sentinel/core";

export class Sample {
  @observable text: string = "";
  @observable number: number | null = null;
  @observable date: Date | null = null;
  @observable bool: boolean = false;
  @observable enum: SampleEnum | null = null;
  @observable option: string | null = null;
  @observable multiOption: string[] = [];

  // Nested/dynamic models can be tracked with @nested annotation
  @nested @observable nested = new Other();
  @nested @observable 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");
    });
  }

  @action.bound
  addNewArrayItem() {
    this.array.push(new Other());
  }
}
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

import { observer } from "mobx-react-lite";
import { Form } from "@mobx-sentinel/form";
import { useFormHandler } from "@mobx-sentinel/react";
import "@mobx-sentinel/react/dist/extension"; // Makes .bindTextInput() and other bind methods available.

const SampleForm: React.FC<{ model: Sample }> = observer(({ model }) => {
  // Get the form instance for the model.
  // It's guaranteed to be 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) => {
    // TODO: Serialize the model and send it to a server
    return true;
  });

  return (
    <>
      <div className="field">
        <label {...form.bindLabel(["text", "bool"])}>Text input &amp; 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>
        <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>
    </>
  );
});
const OtherForm: React.FC<{ model: Other }> = observer(({ model }) => {
  // Forms are completely independent.
  // A nested form doesn't need to know the parent form.
  const form = Form.get(model);

  return (...);
});

Alternatives

Criteria: [T] Type-safe interfaces. [B] Binding for UI. [C] Class-based implementation.

Repository Stars Tests T B C
TypeScript mobx-react-form GitHub stars Codecov Coverage
TypeScript formstate GitHub stars Adequate
TypeScript formst GitHub stars N/A
TypeScript smashing-form GitHub stars Sparse
TypeScript formstate-x GitHub stars Coverage Status
JavaScript mobx-form-validate GitHub stars N/A
JavaScript mobx-form GitHub stars N/A
JavaScript mobx-schema-form GitHub stars Sparse
TypeScript mobx-form-schema GitHub stars Jest coverage
JavaScript mobx-form-store GitHub stars Adequate
TypeScript mobx-form-reactions GitHub stars N/A
...and many more <10

Milestones

Check out https://github.com/creasty/mobx-sentinel/milestones

License

MIT

Package Sidebar

Install

npm i @mobx-sentinel/react

Weekly Downloads

98

Version

0.2.3

License

MIT

Unpacked Size

145 kB

Total Files

18

Last publish

Collaborators

  • creasty