Skip to content

Commit 6a8d5cd

Browse files
committed
Add docs for models
1 parent 8218198 commit 6a8d5cd

2 files changed

Lines changed: 540 additions & 0 deletions

File tree

content/en/guide/v10/signals.md

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,3 +702,230 @@ If you're using Preact Signals in your application, there are specialized debugg
702702
- **[Signals DevTools](https://github.com/preactjs/signals/blob/main/packages/devtools-ui)** - Visual DevTools UI for debugging and visualizing Preact Signals in real-time. You can embed it directly in your page for demos, or integrate it into custom tooling.
703703
704704
> **Note:** These are framework-agnostic tools from the Signals library. While they work great with Preact, they're not Preact-specific.
705+
706+
## Models
707+
708+
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.
709+
710+
### createModel(factory)
711+
712+
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.
713+
714+
```js
715+
import { signal, computed, effect, createModel } from '@preact/signals';
716+
717+
const CounterModel = createModel((initialCount = 0) => {
718+
const count = signal(initialCount);
719+
const doubled = computed(() => count.value * 2);
720+
721+
effect(() => {
722+
console.log('Count changed:', count.value);
723+
});
724+
725+
return {
726+
count,
727+
doubled,
728+
increment() {
729+
count.value++;
730+
},
731+
decrement() {
732+
count.value--;
733+
}
734+
};
735+
});
736+
737+
// Create a new model instance using `new`
738+
const counter = new CounterModel(5);
739+
counter.increment(); // Updates are automatically batched
740+
console.log(counter.count.value); // 6
741+
console.log(counter.doubled.value); // 12
742+
743+
// Clean up all effects when done
744+
counter[Symbol.dispose]();
745+
```
746+
747+
#### Key Features
748+
749+
- **Factory arguments**: Factory functions can accept arguments for initialization, making models reusable with different configurations.
750+
- **Automatic batching**: All methods returned from the factory are automatically wrapped as actions, meaning state updates within them are batched and untracked.
751+
- **Automatic effect cleanup**: Effects created during model construction are captured and automatically disposed when the model is disposed via `Symbol.dispose`.
752+
- **Composable models**: Models compose naturally - effects from nested models are captured by the parent and disposed together when the parent is disposed.
753+
754+
#### Model Composition
755+
756+
Models can be nested within other models. When a parent model is disposed, all effects from nested models are automatically cleaned up:
757+
758+
```js
759+
const TodoItemModel = createModel((text) => {
760+
const completed = signal(false);
761+
762+
return {
763+
text,
764+
completed,
765+
toggle() {
766+
completed.value = !completed.value;
767+
}
768+
};
769+
});
770+
771+
const TodoListModel = createModel(() => {
772+
const items = signal([]);
773+
774+
return {
775+
items,
776+
addTodo(text) {
777+
const todo = new TodoItemModel(text);
778+
items.value = [...items.value, todo];
779+
},
780+
removeTodo(todo) {
781+
items.value = items.value.filter(t => t !== todo);
782+
todo[Symbol.dispose]();
783+
}
784+
};
785+
});
786+
787+
const todoList = new TodoListModel();
788+
todoList.addTodo('Buy groceries');
789+
todoList.addTodo('Walk the dog');
790+
791+
// Disposing the parent also cleans up all nested model effects
792+
todoList[Symbol.dispose]();
793+
```
794+
795+
### action(fn)
796+
797+
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:
798+
799+
```js
800+
import { signal, action } from '@preact/signals';
801+
802+
const count = signal(0);
803+
804+
const incrementBy = action((amount) => {
805+
count.value += amount;
806+
});
807+
808+
incrementBy(5); // Batched update
809+
```
810+
811+
### useModel(modelOrFactory)
812+
813+
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.
814+
815+
```jsx
816+
import { signal, createModel } from '@preact/signals';
817+
import { useModel } from '@preact/signals'; // or '@preact/signals-react'
818+
819+
const CounterModel = createModel(() => ({
820+
count: signal(0),
821+
increment() {
822+
this.count.value++;
823+
}
824+
}));
825+
826+
function Counter() {
827+
const model = useModel(CounterModel);
828+
829+
return (
830+
<button onClick={() => model.increment()}>
831+
Count: {model.count}
832+
</button>
833+
);
834+
}
835+
```
836+
837+
For models that require constructor arguments, wrap the instantiation in a factory function:
838+
839+
```jsx
840+
const CounterModel = createModel((initialCount) => ({
841+
count: signal(initialCount),
842+
increment() {
843+
this.count.value++;
844+
}
845+
}));
846+
847+
function Counter({ initialValue }) {
848+
// Use a factory function to pass arguments
849+
const model = useModel(() => new CounterModel(initialValue));
850+
851+
return (
852+
<button onClick={() => model.increment()}>
853+
Count: {model.count}
854+
</button>
855+
);
856+
}
857+
```
858+
859+
### Recommended Patterns
860+
861+
#### Explicit ReadonlySignal Pattern
862+
863+
For better encapsulation, declare your model interface explicitly and use `ReadonlySignal` for signals that should only be modified through actions:
864+
865+
```ts
866+
import { signal, computed, createModel, ReadonlySignal } from '@preact/signals';
867+
868+
interface Counter {
869+
count: ReadonlySignal<number>;
870+
doubled: ReadonlySignal<number>;
871+
increment(): void;
872+
decrement(): void;
873+
}
874+
875+
const CounterModel = createModel<Counter>(() => {
876+
const count = signal(0);
877+
const doubled = computed(() => count.value * 2);
878+
879+
return {
880+
count,
881+
doubled,
882+
increment() {
883+
count.value++;
884+
},
885+
decrement() {
886+
count.value--;
887+
}
888+
};
889+
});
890+
891+
const counter = new CounterModel();
892+
counter.increment(); // OK
893+
counter.count.value = 10; // TypeScript error: Cannot assign to 'value'
894+
```
895+
896+
#### Custom Dispose Logic
897+
898+
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:
899+
900+
```js
901+
const WebSocketModel = createModel((url) => {
902+
const messages = signal([]);
903+
const ws = new WebSocket(url);
904+
905+
ws.onmessage = (e) => {
906+
messages.value = [...messages.value, e.data];
907+
};
908+
909+
// This effect runs once; its cleanup runs on dispose
910+
effect(() => {
911+
return () => {
912+
ws.close();
913+
};
914+
});
915+
916+
return {
917+
messages,
918+
send(message) {
919+
ws.send(message);
920+
}
921+
};
922+
});
923+
924+
const chat = new WebSocketModel('wss://example.com/chat');
925+
chat.send('Hello!');
926+
927+
// Closes the WebSocket connection on dispose
928+
chat[Symbol.dispose]();
929+
```
930+
931+
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.

0 commit comments

Comments
 (0)