Skip to content

Commit 4114070

Browse files
authored
Add docs for models (#1373)
1 parent 8218198 commit 4114070

File tree

2 files changed

+536
-0
lines changed

2 files changed

+536
-0
lines changed

content/en/guide/v10/signals.md

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,51 @@ To enable this optimization, pass the signal into JSX instead of accessing its `
498498

499499
A similar rendering optimization is also supported when passing signals as props on DOM elements.
500500

501+
## Models
502+
503+
Models provide a structured way to build reactive state containers that encapsulate signals, computed values, effects, and actions. They offer a clean pattern for organizing complex state logic while ensuring automatic cleanup and batched updates.
504+
505+
As applications grow in complexity, managing state with individual signals can become unwieldy. Models solve this by bundling related signals, computed values, and actions together into cohesive units. This makes your code more maintainable, testable, and easier to reason about.
506+
507+
### Why Use Models?
508+
509+
Models offer several key benefits:
510+
511+
- **Encapsulation**: Group related state and logic together, making it clear what belongs where
512+
- **Automatic cleanup**: Effects created in models are automatically disposed when the model is disposed, preventing memory leaks
513+
- **Automatic batching**: All methods are automatically wrapped as actions, ensuring optimal performance
514+
- **Composability**: Models can be nested and composed, with parent models automatically managing child model lifecycles
515+
- **Reusability**: Models can accept initialization parameters, making them reusable across different contexts
516+
- **Testability**: Models can be instantiated and tested in isolation without requiring component rendering
517+
518+
Here's a simple example showing how models organize state:
519+
520+
```js
521+
import { signal, computed, createModel } from '@preact/signals';
522+
523+
const CounterModel = createModel((initialCount = 0) => {
524+
const count = signal(initialCount);
525+
const doubled = computed(() => count.value * 2);
526+
527+
return {
528+
count,
529+
doubled,
530+
increment() {
531+
count.value++;
532+
},
533+
decrement() {
534+
count.value--;
535+
}
536+
};
537+
});
538+
539+
const counter = new CounterModel(5);
540+
counter.increment();
541+
console.log(counter.count.value); // 6
542+
```
543+
544+
For more details on how to use models in your components and the full API reference, see the [Model APIs](#createmodelfactory) in the API section below.
545+
501546
## API
502547

503548
This section is an overview of the signals API. It's aimed to be a quick reference for folks who already know how to use signals and need a reminder of what's available.
@@ -604,6 +649,229 @@ effect(() => {
604649
});
605650
```
606651

652+
### createModel(factory)
653+
654+
The `createModel(factory)` function creates a model constructor from a factory function. The factory function can accept arguments for initialization and should return an object containing signals, computed values, and action methods.
655+
656+
```js
657+
import { signal, computed, effect, createModel } from '@preact/signals';
658+
659+
const CounterModel = createModel((initialCount = 0) => {
660+
const count = signal(initialCount);
661+
const doubled = computed(() => count.value * 2);
662+
663+
effect(() => {
664+
console.log('Count changed:', count.value);
665+
});
666+
667+
return {
668+
count,
669+
doubled,
670+
increment() {
671+
count.value++;
672+
},
673+
decrement() {
674+
count.value--;
675+
}
676+
};
677+
});
678+
679+
// Create a new model instance using `new`
680+
const counter = new CounterModel(5);
681+
counter.increment(); // Updates are automatically batched
682+
console.log(counter.count.value); // 6
683+
console.log(counter.doubled.value); // 12
684+
685+
// Clean up all effects when done
686+
counter[Symbol.dispose]();
687+
```
688+
689+
#### Key Features
690+
691+
- **Factory arguments**: Factory functions can accept arguments for initialization, making models reusable with different configurations.
692+
- **Automatic batching**: All methods returned from the factory are automatically wrapped as actions, meaning state updates within them are batched and untracked.
693+
- **Automatic effect cleanup**: Effects created during model construction are captured and automatically disposed when the model is disposed via `Symbol.dispose`.
694+
- **Composable models**: Models compose naturally - effects from nested models are captured by the parent and disposed together when the parent is disposed.
695+
696+
#### Model Composition
697+
698+
Models can be nested within other models. When a parent model is disposed, all effects from nested models are automatically cleaned up:
699+
700+
```js
701+
const TodoItemModel = createModel((text) => {
702+
const completed = signal(false);
703+
704+
return {
705+
text,
706+
completed,
707+
toggle() {
708+
completed.value = !completed.value;
709+
}
710+
};
711+
});
712+
713+
const TodoListModel = createModel(() => {
714+
const items = signal([]);
715+
716+
return {
717+
items,
718+
addTodo(text) {
719+
const todo = new TodoItemModel(text);
720+
items.value = [...items.value, todo];
721+
},
722+
removeTodo(todo) {
723+
items.value = items.value.filter(t => t !== todo);
724+
todo[Symbol.dispose]();
725+
}
726+
};
727+
});
728+
729+
const todoList = new TodoListModel();
730+
todoList.addTodo('Buy groceries');
731+
todoList.addTodo('Walk the dog');
732+
733+
// Disposing the parent also cleans up all nested model effects
734+
todoList[Symbol.dispose]();
735+
```
736+
737+
### action(fn)
738+
739+
The `action(fn)` function wraps a function to run in a batched and untracked context. This is useful when you need to create standalone actions outside of a model:
740+
741+
```js
742+
import { signal, action } from '@preact/signals';
743+
744+
const count = signal(0);
745+
746+
const incrementBy = action((amount) => {
747+
count.value += amount;
748+
});
749+
750+
incrementBy(5); // Batched update
751+
```
752+
753+
### useModel(modelOrFactory)
754+
755+
The `useModel` hook is available in both `@preact/signals` and `@preact/signals-react` packages. It handles creating a model instance on first render, maintaining the same instance across re-renders, and automatically disposing the model when the component unmounts.
756+
757+
```jsx
758+
import { signal, createModel } from '@preact/signals';
759+
import { useModel } from '@preact/signals';
760+
761+
const CounterModel = createModel(() => ({
762+
count: signal(0),
763+
increment() {
764+
this.count.value++;
765+
}
766+
}));
767+
768+
function Counter() {
769+
const model = useModel(CounterModel);
770+
771+
return (
772+
<button onClick={() => model.increment()}>
773+
Count: {model.count}
774+
</button>
775+
);
776+
}
777+
```
778+
779+
For models that require constructor arguments, wrap the instantiation in a factory function:
780+
781+
```jsx
782+
const CounterModel = createModel((initialCount) => ({
783+
count: signal(initialCount),
784+
increment() {
785+
this.count.value++;
786+
}
787+
}));
788+
789+
function Counter({ initialValue }) {
790+
// Use a factory function to pass arguments
791+
const model = useModel(() => new CounterModel(initialValue));
792+
793+
return (
794+
<button onClick={() => model.increment()}>
795+
Count: {model.count}
796+
</button>
797+
);
798+
}
799+
```
800+
801+
### Recommended Patterns
802+
803+
#### Explicit ReadonlySignal Pattern
804+
805+
For better encapsulation, declare your model interface explicitly and use `ReadonlySignal` for signals that should only be modified through actions:
806+
807+
```ts
808+
import { signal, computed, createModel, ReadonlySignal } from '@preact/signals';
809+
810+
interface Counter {
811+
count: ReadonlySignal<number>;
812+
doubled: ReadonlySignal<number>;
813+
increment(): void;
814+
decrement(): void;
815+
}
816+
817+
const CounterModel = createModel<Counter>(() => {
818+
const count = signal(0);
819+
const doubled = computed(() => count.value * 2);
820+
821+
return {
822+
count,
823+
doubled,
824+
increment() {
825+
count.value++;
826+
},
827+
decrement() {
828+
count.value--;
829+
}
830+
};
831+
});
832+
833+
const counter = new CounterModel();
834+
counter.increment(); // OK
835+
counter.count.value = 10; // TypeScript error: Cannot assign to 'value'
836+
```
837+
838+
#### Custom Dispose Logic
839+
840+
If your model needs custom cleanup logic that isn't related to signals (such as closing WebSocket connections), use an effect with no dependencies that returns a cleanup function:
841+
842+
```js
843+
const WebSocketModel = createModel((url) => {
844+
const messages = signal([]);
845+
const ws = new WebSocket(url);
846+
847+
ws.onmessage = (e) => {
848+
messages.value = [...messages.value, e.data];
849+
};
850+
851+
// This effect runs once; its cleanup runs on dispose
852+
effect(() => {
853+
return () => {
854+
ws.close();
855+
};
856+
});
857+
858+
return {
859+
messages,
860+
send(message) {
861+
ws.send(message);
862+
}
863+
};
864+
});
865+
866+
const chat = new WebSocketModel('wss://example.com/chat');
867+
chat.send('Hello!');
868+
869+
// Closes the WebSocket connection on dispose
870+
chat[Symbol.dispose]();
871+
```
872+
873+
This pattern mirrors `useEffect(() => { return cleanup }, [])` in React and ensures that cleanup happens automatically when models are composed together - parent models don't need to know about the dispose functions of nested models.
874+
607875
## Utility Components and Hooks
608876

609877
As of v2.1.0, the `@preact/signals/utils` package provides additional utility components and hooks to make working with signals even easier.

0 commit comments

Comments
 (0)