Skip to content

feat: add onchange option to $state #15069

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 63 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
d7876af
feat: add `onchange` option to `$state`
paoloricciuti Jan 20, 2025
82d45a2
fix: create `assignable_proxy` utils to prevent declaring an external…
paoloricciuti Jan 21, 2025
e42c7cd
fix: move logic to proxy inside `set`
paoloricciuti Jan 21, 2025
807ffbb
fix: only call `onchange` once for array mutations (#15073)
Rich-Harris Jan 21, 2025
3353faf
chore: add tests for arrays
paoloricciuti Jan 21, 2025
23df27f
chore: update types for `$state.raw`
paoloricciuti Jan 21, 2025
07499da
fix: add options to `$state.raw` in classes
paoloricciuti Jan 21, 2025
1fb57eb
docs: add docs for state options
paoloricciuti Jan 21, 2025
4ed4351
fix: invoke `onchange` in component constructor
paoloricciuti Jan 21, 2025
e2c2580
fix: move `onchange` call right before inspect effects
paoloricciuti Jan 21, 2025
f013e87
fix: only batch array methods if there's an `onchange` function
paoloricciuti Jan 21, 2025
37888f4
fix: move easier condition up
paoloricciuti Jan 21, 2025
c83d01c
fix: move `onchange` after `inspect` effects
paoloricciuti Jan 21, 2025
4229776
Merge remote-tracking branch 'origin/main' into state-onchange
paoloricciuti Jan 21, 2025
7fc930a
chore: bette phrasing for docs and error
paoloricciuti Jan 22, 2025
7c215bf
fix: notify both `onchange` if proxy is passed into proxy
paoloricciuti Jan 22, 2025
ec77f8b
chore: add error for non-inline options
paoloricciuti Jan 22, 2025
d0d9a36
chore: add test for agglomerated `onchange`
paoloricciuti Jan 22, 2025
e237132
fix: correct types
paoloricciuti Jan 22, 2025
f16e445
chore: push failing test for extrapolated reference
paoloricciuti Jan 23, 2025
316a341
fix: make it work properly with reassigned references
paoloricciuti Jan 23, 2025
55fdccc
fix: make it work with reassigned `length`
paoloricciuti Jan 23, 2025
873cd5f
fix: double log on push
paoloricciuti Jan 24, 2025
35e2afe
fix: test for `simple_set` and `simple_set`
paoloricciuti Jan 24, 2025
19acec4
Merge remote-tracking branch 'origin/main' into state-onchange
paoloricciuti Feb 12, 2025
df62dd6
Merge remote-tracking branch 'origin/main' into state-onchange
paoloricciuti Mar 17, 2025
2a3fb7a
fix: lint and test
paoloricciuti Mar 17, 2025
128c325
fix: remove source onchange from proxy on reassignment
paoloricciuti Mar 17, 2025
3e886c7
fix: add extra check
paoloricciuti Mar 17, 2025
25e03b3
fix: batch assignment to length of an array
paoloricciuti Mar 17, 2025
e1e372b
merge main
Rich-Harris Mar 19, 2025
1b2719f
merge main
Rich-Harris Mar 19, 2025
8d02009
Apply suggestions from code review
Rich-Harris Mar 21, 2025
0d4add1
merge main
Rich-Harris Mar 21, 2025
0b8d2fa
remove static analysis restriction
Rich-Harris Mar 21, 2025
a97465d
simplify
Rich-Harris Mar 21, 2025
e7fa79a
simplify
Rich-Harris Mar 21, 2025
c793cf3
simplify
Rich-Harris Mar 21, 2025
51ecbef
oops
Rich-Harris Mar 21, 2025
b158c8c
unused
Rich-Harris Mar 21, 2025
ea75c5e
tidy up
Rich-Harris Mar 21, 2025
954eb8d
merge main
Rich-Harris Mar 21, 2025
3cb7b79
chore: split tests
paoloricciuti Mar 21, 2025
35e4023
Update packages/svelte/tests/runtime-runes/samples/state-onchange-rea…
Rich-Harris Mar 21, 2025
118e9aa
put flushSync calls on single line — makes it easier to connect the e…
Rich-Harris Mar 21, 2025
36bfef9
not a proxy!
Rich-Harris Mar 21, 2025
0fd4d2a
Merge branch 'main' into state-onchange
Rich-Harris Mar 21, 2025
458ed29
Merge branch 'main' into state-onchange
Rich-Harris Mar 21, 2025
a33ff30
Merge branch 'main' into state-onchange
Rich-Harris Mar 22, 2025
714c042
extract `onchange` callbacks from options (#15579)
Rich-Harris Mar 22, 2025
9f34f7c
merge main
Rich-Harris Apr 12, 2025
af70cef
ValueOptions -> StateOptions
Rich-Harris Apr 12, 2025
bfa0883
Merge branch 'main' into state-onchange
Rich-Harris Apr 12, 2025
bde0252
tweak docs
Rich-Harris Apr 12, 2025
c4182f5
simplify
Rich-Harris Apr 12, 2025
0e57669
cosmetic tweak
Rich-Harris Apr 12, 2025
42f73a0
tweak conditions
Rich-Harris Apr 12, 2025
dbf2b4c
simplify
Rich-Harris Apr 12, 2025
d5f785b
add a failing test
Rich-Harris Apr 12, 2025
d8e60f0
fix
Rich-Harris Apr 12, 2025
6c9380c
failing test
Rich-Harris Apr 12, 2025
ac05b73
fix
Rich-Harris Apr 12, 2025
cfd8ef2
merge main
Rich-Harris Apr 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/red-rules-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': minor
---

feat: add `onchange` option to `$state`
29 changes: 29 additions & 0 deletions documentation/docs/02-runes/02-$state.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,35 @@ person = {

This can improve performance with large arrays and objects that you weren't planning to mutate anyway, since it avoids the cost of making them reactive. Note that raw state can _contain_ reactive state (for example, a raw array of reactive objects).

## State options

Both `$state` and `$state.raw` can optionally accept a second argument. This allows you to specify an `onchange` function that will be called synchronously whenever the state value changes (for `$state` it will also be called for deep mutations).
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
Both `$state` and `$state.raw` can optionally accept a second argument. This allows you to specify an `onchange` function that will be called synchronously whenever the state value changes (for `$state` it will also be called for deep mutations).
Both `$state` and `$state.raw` can optionally accept a second argument. This allows you to specify an `onchange` function that will be called synchronously whenever the state value changes. For a deep `$state` proxy (of a POJO/array), `onchange` is also called for deep mutations, but not for `$state` fields in classes, as these are owned by the corresponding class instance. This includes classes from `svelte/reactivity`, such as [SvelteMap](svelte-reactivity#SvelteMap).


The `onchange` function is untracked so even if you assign within an `$effect` it will not cause unwanted dependencies.

```js
let count = $state(0, {
onchange(){
console.log("count is now", count);
}
});

// this will log "count is now 1"
count++;
```

this could be especially useful if you want to sync some stateful variable that could be mutated without using an effect.

```js
let array = $state([], {
onchange(){
localStorage.setItem('array', JSON.stringify(array));
}
});

array.push(array.length);
```

## `$state.snapshot`

To take a static snapshot of a deeply reactive `$state` proxy, use `$state.snapshot`:
Expand Down
6 changes: 6 additions & 0 deletions documentation/docs/98-reference/.generated/compile-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,12 @@ Cannot access a computed property of a rune
`%name%` is not a valid rune
```

### rune_invalid_options

```
Options for `%rune%` needs to be declared inline
```

### rune_invalid_usage

```
Expand Down
4 changes: 4 additions & 0 deletions packages/svelte/messages/compile-errors/script.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ This turned out to be buggy and unpredictable, particularly when working with de

> `%name%` is not a valid rune

## rune_invalid_options

> Options for `%rune%` needs to be declared inline

## rune_invalid_usage

> Cannot use `%rune%` rune in non-runes mode
Expand Down
10 changes: 10 additions & 0 deletions packages/svelte/src/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ declare module '*.svelte' {
*
* @param initial The initial value
*/
declare function $state<T>(
initial: undefined,
options?: import('svelte').StateOptions
): T | undefined;
declare function $state<T>(initial: T, options?: import('svelte').StateOptions): T;
declare function $state<T>(initial: T): T;
declare function $state<T>(): T | undefined;

Expand Down Expand Up @@ -116,6 +121,11 @@ declare namespace $state {
*
* @param initial The initial value
*/
export function raw<T>(
initial: undefined,
options?: import('svelte').StateOptions
): T | undefined;
export function raw<T>(initial?: T, options?: import('svelte').StateOptions): T;
export function raw<T>(initial: T): T;
export function raw<T>(): T | undefined;
/**
Expand Down
10 changes: 10 additions & 0 deletions packages/svelte/src/compiler/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,16 @@ export function rune_invalid_name(node, name) {
e(node, 'rune_invalid_name', `\`${name}\` is not a valid rune\nhttps://svelte.dev/e/rune_invalid_name`);
}

/**
* Options for `%rune%` needs to be declared inline
* @param {null | number | NodeLike} node
* @param {string} rune
* @returns {never}
*/
export function rune_invalid_options(node, rune) {
e(node, 'rune_invalid_options', `Options for \`${rune}\` needs to be declared inline\nhttps://svelte.dev/e/rune_invalid_options`);
}

/**
* Cannot use `%rune%` rune in non-runes mode
* @param {null | number | NodeLike} node
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,13 @@ export function CallExpression(node, context) {

if ((rune === '$derived' || rune === '$derived.by') && node.arguments.length !== 1) {
e.rune_invalid_arguments_length(node, rune, 'exactly one argument');
} else if (rune === '$state' && node.arguments.length > 1) {
e.rune_invalid_arguments_length(node, rune, 'zero or one arguments');
} else if (rune === '$state' || rune === '$state.raw') {
if (node.arguments.length > 2) {
e.rune_invalid_arguments_length(node, rune, 'at most two arguments');
}
if (node.arguments.length === 2 && node.arguments[1].type !== 'ObjectExpression') {
e.rune_invalid_options(node.arguments[1], rune);
}
}

break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,10 @@ export function client_component(analysis, options) {
}

if (binding?.kind === 'state' || binding?.kind === 'raw_state') {
const value = binding.kind === 'state' ? b.call('$.proxy', b.id('$$value')) : b.id('$$value');
const value =
binding.kind === 'state'
? b.call('$.proxy', b.id('$$value'), b.call('$.get_options', b.id(name)))
: b.id('$$value');
return [getter, b.set(alias ?? name, [b.stmt(b.call('$.set', b.id(name), value))])];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export interface ClientTransformState extends TransformState {
/** turn `foo` into e.g. `$.get(foo)` */
read: (id: Identifier) => Expression;
/** turn `foo = bar` into e.g. `$.set(foo, bar)` */
assign?: (node: Identifier, value: Expression) => Expression;
assign?: (node: Identifier, value: Expression, proxy?: boolean) => Expression;
/** turn `foo.bar = baz` into e.g. `$.mutate(foo, $.get(foo).bar = baz);` */
mutate?: (node: Identifier, mutation: AssignmentExpression | UpdateExpression) => Expression;
/** turn `foo++` into e.g. `$.update(foo)` */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,6 @@ export function build_getter(node, state) {
return node;
}

/**
* @param {Expression} value
* @param {Expression} previous
*/
export function build_proxy_reassignment(value, previous) {
return dev ? b.call('$.proxy', value, b.null, previous) : b.call('$.proxy', value);
}

/**
* @param {FunctionDeclaration | FunctionExpression | ArrowFunctionExpression} node
* @param {ComponentContext} context
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
is_event_attribute
} from '../../../../utils/ast.js';
import { dev, filename, is_ignored, locate_node, locator } from '../../../../state.js';
import { build_proxy_reassignment, should_proxy } from '../utils.js';
import { should_proxy } from '../utils.js';
import { visit_assignment_expression } from '../../shared/assignments.js';

/**
Expand Down Expand Up @@ -65,21 +65,20 @@ function build_assignment(operator, left, right, context) {
context.visit(build_assignment_value(operator, left, right))
);

if (
const needs_proxy =
private_state.kind === 'state' &&
is_non_coercive_operator(operator) &&
should_proxy(value, context.state.scope)
) {
value = build_proxy_reassignment(value, b.member(b.this, private_state.id));
}

if (context.state.in_constructor) {
// inside the constructor, we can assign to `this.#foo.v` rather than using `$.set`,
// since nothing is tracking the signal at this point
return b.assignment(operator, /** @type {Pattern} */ (context.visit(left)), value);
}

return b.call('$.set', left, value);
should_proxy(value, context.state.scope);

return b.call(
// inside the constructor, we use `$.simple_set` rather than using `$.set`,
// that only assign the value and eventually call onchange since nothing is tracking the signal at this point
context.state.in_constructor ? '$.simple_set' : '$.set',
left,
value,
needs_proxy && b.true,
dev && needs_proxy && b.true
);
}
}

Expand Down Expand Up @@ -113,19 +112,17 @@ function build_assignment(operator, left, right, context) {
context.visit(build_assignment_value(operator, left, right))
);

if (
return transform.assign(
object,
value,
!is_primitive &&
binding.kind !== 'prop' &&
binding.kind !== 'bindable_prop' &&
binding.kind !== 'raw_state' &&
context.state.analysis.runes &&
should_proxy(right, context.state.scope) &&
is_non_coercive_operator(operator)
) {
value = build_proxy_reassignment(value, object);
}

return transform.assign(object, value);
binding.kind !== 'prop' &&
binding.kind !== 'bindable_prop' &&
binding.kind !== 'raw_state' &&
context.state.analysis.runes &&
should_proxy(right, context.state.scope) &&
is_non_coercive_operator(operator)
);
}

// mutation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { dev, is_ignored } from '../../../../state.js';
import * as b from '../../../../utils/builders.js';
import { regex_invalid_identifier_chars } from '../../../patterns.js';
import { get_rune } from '../../../scope.js';
import { build_proxy_reassignment, should_proxy } from '../utils.js';
import { should_proxy } from '../utils.js';

/**
* @param {ClassBody} node
Expand Down Expand Up @@ -116,14 +116,22 @@ export function ClassBody(node, context) {
context.visit(definition.value.arguments[0], child_state)
);

let options =
definition.value.arguments.length === 2
? /** @type {Expression} **/ (
context.visit(definition.value.arguments[1], child_state)
)
: undefined;

let proxied = should_proxy(init, context.state.scope);

value =
field.kind === 'state'
? b.call(
'$.state',
should_proxy(init, context.state.scope) ? b.call('$.proxy', init) : init
)
? should_proxy(init, context.state.scope)
? b.call('$.assignable_proxy', init, options)
: b.call('$.state', init, options)
: field.kind === 'raw_state'
? b.call('$.state', init)
? b.call('$.state', init, options)
: field.kind === 'derived_by'
? b.call('$.derived', init)
: b.call('$.derived', b.thunk(init));
Expand Down Expand Up @@ -152,7 +160,7 @@ export function ClassBody(node, context) {
'set',
definition.key,
[value],
[b.stmt(b.call('$.set', member, build_proxy_reassignment(value, prev)))]
[b.stmt(b.call('$.set', member, value, b.true, dev && b.true))]
)
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,28 +113,34 @@ export function VariableDeclaration(node, context) {
const args = /** @type {CallExpression} */ (init).arguments;
const value =
args.length === 0 ? b.id('undefined') : /** @type {Expression} */ (context.visit(args[0]));
let options =
args.length === 2 ? /** @type {Expression} */ (context.visit(args[1])) : undefined;

if (rune === '$state' || rune === '$state.raw') {
/**
* @param {Identifier} id
* @param {Expression} value
* @param {Expression} [options]
*/
const create_state_declarator = (id, value) => {
const create_state_declarator = (id, value, options) => {
const binding = /** @type {import('#compiler').Binding} */ (
context.state.scope.get(id.name)
);
if (rune === '$state' && should_proxy(value, context.state.scope)) {
value = b.call('$.proxy', value);
}
if (is_state_source(binding, context.state.analysis)) {
value = b.call('$.state', value);
const proxied = rune === '$state' && should_proxy(value, context.state.scope);
const is_state = is_state_source(binding, context.state.analysis);
if (proxied && is_state) {
value = b.call('$.assignable_proxy', value, options);
} else if (proxied) {
value = b.call('$.proxy', value, options);
} else if (is_state) {
value = b.call('$.state', value, options);
}
return value;
};

if (declarator.id.type === 'Identifier') {
declarations.push(
b.declarator(declarator.id, create_state_declarator(declarator.id, value))
b.declarator(declarator.id, create_state_declarator(declarator.id, value, options))
);
} else {
const tmp = context.state.scope.generate('tmp');
Expand All @@ -147,7 +153,7 @@ export function VariableDeclaration(node, context) {
return b.declarator(
path.node,
binding?.kind === 'state' || binding?.kind === 'raw_state'
? create_state_declarator(binding.node, value)
? create_state_declarator(binding.node, value, options)
: value
);
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/** @import { Identifier } from 'estree' */
/** @import { ComponentContext, Context } from '../../types' */
import { is_state_source } from '../../utils.js';
import { is_state_source, should_proxy } from '../../utils.js';
import * as b from '../../../../../utils/builders.js';
import { dev } from '../../../../../state.js';

/**
* Turns `foo` into `$.get(foo)`
Expand All @@ -24,8 +25,8 @@ export function add_state_transformers(context) {
) {
context.state.transform[name] = {
read: binding.declaration_kind === 'var' ? (node) => b.call('$.safe_get', node) : get_value,
assign: (node, value) => {
let call = b.call('$.set', node, value);
assign: (node, value, proxy = false) => {
let call = b.call('$.set', node, value, proxy && b.true, dev && proxy && b.true);

if (context.state.scope.get(`$${node.name}`)?.kind === 'store_sub') {
call = b.call('$.store_unsub', call, b.literal(`$${node.name}`), b.id('$$stores'));
Expand Down
2 changes: 2 additions & 0 deletions packages/svelte/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,4 +351,6 @@ export type MountOptions<Props extends Record<string, any> = Record<string, any>
props: Props;
});

export { ValueOptions as StateOptions } from './internal/client/types.js';

export * from './index-client.js';
1 change: 1 addition & 0 deletions packages/svelte/src/internal/client/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const HEAD_EFFECT = 1 << 19;
export const EFFECT_HAS_DERIVED = 1 << 20;

export const STATE_SYMBOL = Symbol('$state');
export const PROXY_ONCHANGE_SYMBOL = Symbol('proxy onchange');
export const STATE_SYMBOL_METADATA = Symbol('$state metadata');
export const LEGACY_PROPS = Symbol('legacy props');
export const LOADING_ATTR_SYMBOL = Symbol('');
11 changes: 9 additions & 2 deletions packages/svelte/src/internal/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,14 @@ export {
user_effect,
user_pre_effect
} from './reactivity/effects.js';
export { mutable_state, mutate, set, state } from './reactivity/sources.js';
export {
mutable_state,
mutate,
set,
simple_set,
state,
get_options
} from './reactivity/sources.js';
export {
prop,
rest_props,
Expand Down Expand Up @@ -152,7 +159,7 @@ export {
} from './runtime.js';
export { validate_binding, validate_each_keys } from './validate.js';
export { raf } from './timing.js';
export { proxy } from './proxy.js';
export { proxy, assignable_proxy } from './proxy.js';
export { create_custom_element } from './dom/elements/custom-element.js';
export {
child,
Expand Down
Loading
Loading