-
-
Notifications
You must be signed in to change notification settings - Fork 408
Introduce: localCopy #1130
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
NullVoxPopuli
wants to merge
3
commits into
main
Choose a base branch
from
nvp/localCopy
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+228
−0
Open
Introduce: localCopy #1130
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,228 @@ | ||
| --- | ||
| stage: accepted | ||
| start-date: 2025-08-12T00:00:00.000Z | ||
| release-date: # In format YYYY-MM-DDT00:00:00.000Z | ||
| release-versions: | ||
| teams: | ||
| - framework | ||
| - typescript | ||
| prs: | ||
| accepted: https://github.com/emberjs/rfcs/pull/1130 | ||
| project-link: | ||
| suite: | ||
| --- | ||
|
|
||
| # Add `localCopy` reactive primitive | ||
|
|
||
| ## Summary | ||
|
|
||
| This RFC proposes adding `localCopy` and `@localCopy` to Ember's reactive primitives, providing a way to create local, mutable copies of reactive state that maintain synchronization with their source while allowing local and independent updates. | ||
|
|
||
| ## Motivation | ||
|
|
||
| Modern reactive applications often need to create local, editable copies of remote or shared state. Common use cases include: | ||
|
|
||
| 1. Controlled form inputs can maintain local tracked state until form submission (submit commonly re-synchronizes the source of truth) | ||
| 3. Allowing users to make changes that can be saved or discarded | ||
| 3. Preventing child components from accidentally mutating parent state | ||
|
|
||
| Currently, developers must manually implement this pattern using multiple `@tracked` properties and bespoke synchronization logic. This leads to boilerplate code and potential bugs around state consistency. | ||
|
|
||
| `@localCopy` has existed for a long time in a library, [`tracked-toolbox`](https://github.com/tracked-tools/tracked-toolbox) and has proven its value -- just as `@cached` did (also originally defined in `tracked-toolbox` (and is still exported from there)). | ||
|
|
||
| `@localCopy` solves all of the above as well as any other use case for "forking tracked state". | ||
|
|
||
|
|
||
| ## Detailed design | ||
|
|
||
| The test-suite of `@localCopy` from [`tracked-toolbox`](https://github.com/tracked-tools/tracked-toolbox/blob/master/test-app/tests/unit/local-copy-test.js) as well as that of [`localCopy`](https://github.com/proposal-signals/signal-utils/blob/main/tests/local-copy.test.ts) and [`@localCopy`](https://github.com/proposal-signals/signal-utils/blob/main/tests/%40localCopy.test.ts) from `signal-utils` describe the acceptance criteria for this utility. | ||
|
|
||
| ### API Overview | ||
|
|
||
| The `localCopy` export is available in both function and decorator forms: | ||
|
|
||
| ```typescript | ||
| import { localCopy } from '@ember/reactive'; | ||
| ``` | ||
|
|
||
| Because function usage and decorator usage have different arity, we can utilize the same export for all styles of usage. | ||
|
|
||
| ### Function Form | ||
|
|
||
| The function form creates a reactive primitive that implements the same interface as a [Cell](https://github.com/emberjs/rfcs/pull/1071): | ||
|
|
||
| ```typescript | ||
| function localCopy<T>( | ||
| source: () => T, | ||
| options?: { | ||
| equals?: (a: T, b: T) => boolean; | ||
| description?: string; | ||
| } | ||
| ): Cell<T>; | ||
| ``` | ||
|
|
||
| See [Cell](https://github.com/emberjs/rfcs/pull/1071/files#diff-fa519f723fb6a105edfe2779ca6e4593bce756817da177495468021b37c46f3eR114). | ||
|
|
||
| #### Basic Usage | ||
|
|
||
| ```typescript | ||
| import { tracked } from '@glimmer/tracking'; | ||
| import { localCopy } from '@ember/reactive'; | ||
|
|
||
| class FormComponent extends Component { | ||
| @tracked user = { name: 'John', email: '[email protected]' }; | ||
|
|
||
| /** | ||
| * second argument is optional | ||
| */ | ||
| userCopy = localCopy(() => this.user, { | ||
| equals: (a, b) => a.name === b.name && a.email === b.email | ||
| }); | ||
|
|
||
| @action | ||
| updateName(newName) { | ||
| // Updates the local copy without affecting the original (this.user) | ||
| this.userCopy.update(user => ({ ...user, name: newName })); | ||
| // or | ||
| this.userCopy.current = { name: 'John', email: '[email protected]' }; | ||
| } | ||
|
|
||
| @action | ||
| save() { | ||
| this.user = this.userCopy.current; | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Decorator Form | ||
|
|
||
| The decorator form provides convenient syntax for class properties: | ||
|
|
||
| ```glimmer-ts | ||
| import { localCopy } from '@ember/reactive'; | ||
|
|
||
| class EditableProfile extends Component { | ||
| @tracked profile = { name: 'Jane', bio: 'Developer' }; | ||
|
|
||
| /** | ||
| * second argument is optional. | ||
| * first argument must be a string because the left-hand side of a property does not have access to the instance. | ||
| */ | ||
| @localCopy('profile', { | ||
| equals: (a, b) => a.name === b.name && a.bio === b.bio | ||
| }) profileCopy; | ||
|
|
||
| updateBio(newBio) { | ||
| this.profileCopy = { name: 'Jane', bio: 'Vlogger' }; | ||
| } | ||
|
|
||
| <template> | ||
| Bio: {{this.profileCopy.bio}} | ||
| Name: {{this.profileCopy.name}} | ||
|
|
||
| <button {{on 'click' this.updateBio}}>Update</button> | ||
| </template> | ||
| } | ||
| ``` | ||
|
|
||
| ### Cell Interface Compatibility | ||
|
|
||
| The function form of `localCopy` implements the same interface as a [Cell](https://github.com/emberjs/rfcs/pull/1071), making it interoperable with other Cell-based APIs: | ||
|
|
||
| ```typescript | ||
| // Both have the same core interface | ||
| const cell = cell(initialValue); | ||
| const copy = localCopy(() => sourceValue); | ||
|
|
||
| // All of these work with both: | ||
| cell.current = newValue; | ||
| copy.current = newValue; | ||
|
|
||
| cell.set(newValue); | ||
| copy.set(newValue); | ||
|
|
||
| cell.update(fn => fn(current)); | ||
| copy.update(fn => fn(current)); | ||
|
|
||
| cell.freeze(); | ||
| copy.freeze(); | ||
| ``` | ||
|
|
||
| This compatibility ensures `localCopy` can be used anywhere a Cell is expected and the decorator usage matches the API of `@tracked`, enabling composition with all existing concepts. | ||
|
|
||
|
|
||
| ## How we teach this | ||
|
|
||
| ### Conceptual Introduction | ||
|
|
||
| `localCopy` should be introduced as a "reactive copy" or "state forking" primitive that solves the common pattern of wanting to edit data without immediately affecting the original. | ||
|
|
||
| Potential guides content from the tracked-toolbox README: | ||
|
|
||
|
|
||
| `@localCopy` Creates a local copy of a remote value. The local copy can be updated locally, | ||
| but will also update if the remote value ever changes: | ||
|
|
||
| ```js | ||
| import Component from '@glimmer/component'; | ||
| import { localCopy } from '@ember/reactive'; | ||
|
|
||
| export default class CustomInput extends Component { | ||
| // This defaults to the value of this.args.text | ||
| @localCopy('args.text') text; | ||
|
|
||
| updateText({ target }) { | ||
| // this updates the value of `text`, but does _not_ update | ||
| // the value of `this.args.text` | ||
| this.text = String(target.value); | ||
|
|
||
| this.args.onInput?.(this.text); | ||
| } | ||
|
|
||
| <template> | ||
| <input {{on 'input' this.updateText}}> | ||
| ... | ||
| </template> | ||
| } | ||
| ``` | ||
|
|
||
| In this example, if `args.text` were to ever change externally, then the local | ||
| `text` property would also update. The local copy is not a clone of the value | ||
| passed in, it is the actual value itself, so values like arrays and objects | ||
| will still be affected upstream if their values are changed. | ||
|
|
||
| An initializer can be provided as the second parameter to the decorator. This | ||
| will be used if the remote value is `undefined`: | ||
|
|
||
| ```js | ||
| export default class CustomInput extends Component { | ||
| @localCopy('args.text') text; | ||
| } | ||
| ``` | ||
|
|
||
| If the initializer is a function, it will be called and its return value will be | ||
| used as the default value. | ||
|
|
||
|
|
||
| ### Differences from tracked-toolbox | ||
|
|
||
| - localCopy's second argument is _not_ a "placeholder" | ||
| to use a placeholder, or fallback value, folks would want to use a getter: | ||
| ```js | ||
| get textWithDefault() { | ||
| return this.text ?? 'default value'; | ||
| } | ||
| ``` | ||
|
|
||
| ## Drawbacks | ||
|
|
||
| - Adds another reactive tool to learn and understand | ||
| (tracked-toolbox is already very popular though) | ||
|
|
||
| ## Alternatives | ||
|
|
||
| - continue using tracked-toolbox | ||
|
|
||
| ## Unresolved questions | ||
|
|
||
| n/a | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do we need this? the string references kinda make it awkward / less type safe