Skip to content

Commit 7ae08e2

Browse files
committed
Add docs for models
1 parent ecb8925 commit 7ae08e2

2 files changed

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