Skip to content

Conversation

@andrewiggins
Copy link
Member

Summary

This PR adds createModel and action to @preact/signals-core, providing a structured way to build reactive state containers that encapsulate signals, computed values, effects, and actions.

const CounterModel = createModel((initialCount = 0) => {
	const count = signal(initialCount);
	const doubled = computed(() => count.value * 2);

	effect(() => {
		console.log("Count changed:", count.value);
	});

	return {
		count,
		doubled,
		increment() {
			count.value++;
		},
	};
});

const counter = new CounterModel(5);
counter.increment(); // Updates are automatically batched
counter[Symbol.dispose](); // Cleans up all effects

Key features:

  • Factory functions can accept arguments for initialization
  • All methods are automatically wrapped as actions (batched & untracked)
  • Effects created during model construction are captured and disposed when the model is disposed via Symbol.dispose
  • Models compose naturally - effects from nested models are captured by the parent and disposed together when the parent is disposed
  • TypeScript validates that models only contain signals, actions, or nested objects with signals/actions

Design decisions

No classes or reflection

The implementation avoids using ES classes internally. Using a class would require reflecting onto a class's constructor and the current signals implementation avoids reflection and proxies, so this follows a similar design philosophy. A class-based API could be built on top of this primitive, like so (shoutout @developit for this neat little hack):

class BaseModelImpl implements Disposable {
	[Symbol.dispose](): void {}
}

export const BaseModel = new Proxy(BaseModelImpl, {
	construct(target, args, newTarget) {
		return createModel(() => Reflect.construct(target, args, newTarget));
	},
}) as unknown as typeof BaseModelImpl;

Using new to instantiate models

The public types require using new to instantiate models. This helps disambiguate the factory function passed into createModel from the returned constructor. It's easier to explain that "createModel accepts a factory and returns a class" than "createModel accepts a factory and returns a factory."

In other words, this:

const PersonModel = createModel((name: string) => ({ ... }));
const person = new PersonModel("John");

is easier to understand than:

const createPerson = createModel((name: string) => ({ ... }));
const person = createPerson("John");

Using new also communicates that each call creates a fresh instance with independent state.

Internally, createModel returns a plain function that can be called without new for simplicity, but the public types enforce new for clarity.

Automatically capture effects implement a dispose function

Effects declared inside a model's factory function are captured by createModel in order to automatically implement a dispose function on the model (exposed as [Symbol.dispose]). This design avoids models needing to manually wire up effect dispose from nested models to the model interface.

Factory functions should NOT return dispose functions

If a model needs to run custom logic when it is diposed (that may not be related to signals), it should not return a dispose() or [Symbol.dispose]. When composing models, this dispose function isn't guarenteed to get called as parent models would need to know that your model has a dispose and manually wire it up.

Instead for custom cleanup logic, the recommended pattern is to declare an effect with no signal dependencies that returns a cleanup function that runs the desired cleanup logic. (see "Dispose pattern" below).

Recommended patterns

Explicit readonly pattern

Declare your model interface explicitly and use ReadonlySignal for signals that should only be modified through actions. This ensures only actions can modify signals, giving you better insight and control over state changes:

import {
	signal,
	computed,
	createModel,
	ReadonlySignal,
} from "@preact/signals-core";

interface Counter {
	count: ReadonlySignal<number>;
	doubled: ReadonlySignal<number>;
	increment(): void;
	decrement(): void;
}

const CounterModel = createModel<Counter>(() => {
	const count = signal(0);
	const doubled = computed(() => count.value * 2);

	return {
		count,
		doubled,
		increment() {
			count.value++;
		},
		decrement() {
			count.value--;
		},
	};
});

const counter = new CounterModel();
counter.increment(); // OK
counter.count.value = 10; // TypeScript error: Cannot assign to 'value' because it is a read-only property

Dispose pattern

Generally, if you delcare an effect that has cleanup logic, that cleanup logic will before each execution of the effect function (aka whenever the signals your effect relies on update).

However, if you have cleanup logic that needs to run on model dispose that doesn't depend on signals, define an effect that uses no signals but returns your cleanup function. This mirrors the useEffect(() => { return cleanup }, []) pattern in React:

const WebSocketModel = createModel((url: string) => {
	const messages = signal<string[]>([]);
	const ws = new WebSocket(url);

	ws.onmessage = e => {
		messages.value = [...messages.value, e.data];
	};

	// This effect runs once and cleanup is called on dispose
	effect(() => {
		return () => {
			ws.close();
		};
	});

	return {
		messages,
		send(message: string) {
			ws.send(message);
		},
	};
});

This pattern is recommended for custom dispose behavior because it allows models to compose naturally - nested models will have their effects cleaned up automatically without manually wiring up dispose functions.

Open Questions

  • Should createModel be in its own package? It would require accessing Effect internals to observe effect creation.
  • Do people like the #region markers? They produce helpful headings in VSCode's scrollbar preview. Happy to remove them if people don't like them.
image

Future work

  • Add useModel hook to Preact & React adapters
  • Extend debug transform to add names to models & actions, and use model name in signals, computeds, effects, and actions declared within the model
  • Extend debug tooling to understand models and actions

@changeset-bot
Copy link

changeset-bot bot commented Jan 14, 2026

🦋 Changeset detected

Latest commit: 6bc1669

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@preact/signals-core Minor
@preact/signals-debug Major
preact-signals-devtools Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@netlify
Copy link

netlify bot commented Jan 14, 2026

Deploy Preview for preact-signals-demo ready!

Name Link
🔨 Latest commit 6bc1669
🔍 Latest deploy log https://app.netlify.com/projects/preact-signals-demo/deploys/696efc0dc8bdae00088664d6
😎 Deploy Preview https://deploy-preview-812--preact-signals-demo.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions
Copy link
Contributor

github-actions bot commented Jan 14, 2026

Size Change: +1.22 kB (+0.78%)

Total Size: 157 kB

Filename Size Change
docs/dist/assets/bench.********.js 1.58 kB -2 B (-0.13%)
docs/dist/assets/client.********.js 46.3 kB -1 B (0%)
docs/dist/assets/devtools.********.js 906 B -211 B (-18.89%) 👏
docs/dist/assets/EmbeddedDevtools.********.js 15.5 kB +60 B (+0.39%)
docs/dist/assets/index.********.js 7.1 kB +64 B (+0.91%)
docs/dist/assets/signals-core.module.********.js 1.76 kB +199 B (+12.76%) ⚠️
docs/dist/assets/style.********.css 4.04 kB +247 B (+6.51%) 🔍
docs/dist/assets/Unmount.********.js 648 B +1 B (+0.15%)
docs/dist/assets/utils.module.********.js 368 B +368 B (new file) 🆕
docs/dist/basic-********.js 244 B -2 B (-0.81%)
docs/dist/nesting-********.js 1.13 kB -1 B (-0.09%)
docs/dist/react-********.js 238 B -4 B (-1.65%)
packages/core/dist/signals-core.js 1.79 kB +203 B (+12.76%) ⚠️
packages/core/dist/signals-core.mjs 1.78 kB +181 B (+11.28%) ⚠️
packages/devtools-ui/dist/devtools-ui.js 14.2 kB +26 B (+0.18%)
packages/devtools-ui/dist/devtools-ui.mjs 13.7 kB +27 B (+0.2%)
packages/preact/dist/signals.js 1.72 kB +18 B (+1.06%)
packages/preact/dist/signals.mjs 1.68 kB +11 B (+0.66%)
packages/react/dist/signals.js 210 B +22 B (+11.7%) ⚠️
packages/react/dist/signals.mjs 162 B +12 B (+8%) 🔍
ℹ️ View Unchanged
Filename Size
docs/dist/assets/jsxRuntime.module.********.js 297 B
docs/dist/assets/preact.module.********.js 4.76 kB
docs/dist/assets/signals.module.********.js 2.57 kB
docs/dist/assets/style.********.js 21 B
packages/debug/dist/debug.js 3.56 kB
packages/debug/dist/debug.mjs 3.09 kB
packages/devtools-adapter/dist/devtools-adapter.js 2.17 kB
packages/devtools-adapter/dist/devtools-adapter.mjs 1.88 kB
packages/preact-transform/dist/signals-*********.js 1.3 kB
packages/preact-transform/dist/signals-transform.mjs 1.29 kB
packages/preact-transform/dist/signals-transform.umd.js 1.42 kB
packages/react-transform/dist/signals-*********.js 6.65 kB
packages/react-transform/dist/signals-transform.mjs 5.85 kB
packages/react-transform/dist/signals-transform.umd.js 6.77 kB

compressed-size-action

};
});

function useModel<TModel>(constructModel: () => Model<TModel>): Model<TModel> {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hook will get added to the Preact & React adapters in a follow up PR

Comment on lines +129 to +130
styled.style.left = rect.left + scrollX + "px";
styled.style.top = rect.top + scrollY + "px";
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Little fix for the render flashing to take into account a scrolled window. Ran into this with the model todo app.


//#endregion Effect

//#region Action
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love the regions, we should be good to export this from here because it's a tree-shakeable package

? TModel[Key]
: TModel[Key] extends object
? ValidateModel<TModel[Key]>
: `Property ${Key extends string ? `'${Key}' ` : ""}is not a Signal, Action, or an object that contains only Signals and Actions.`;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we allow for primitive types here to allow our users to leverage getters or are we drawing that out completely?

Added debugData to the state for debugging purposes in the demo.
Format debug data output for better readability.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants