Skip to content
Open
Changes from 11 commits
Commits
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
343 changes: 343 additions & 0 deletions text/1071-cell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,343 @@
---
stage: accepted
start-date: 2025-01-19T00:00:00.000Z
release-date: # In format YYYY-MM-DDT00:00:00.000Z
release-versions:
teams: # delete teams that aren't relevant
- cli
- data
- framework
- learning
- steering
- typescript
prs:
accepted: https://github.com/emberjs/rfcs/pull/1071
project-link:
suite:
---

<!---
Directions for above:

stage: Leave as is
start-date: Fill in with today's date, 2032-12-01T00:00:00.000Z
release-date: Leave as is
release-versions: Leave as is
teams: Include only the [team(s)](README.md#relevant-teams) for which this RFC applies
prs:
accepted: Fill this in with the URL for the Proposal RFC PR
project-link: Leave as is
suite: Leave as is
-->

<-- Replace "RFC title" with the title of your RFC -->
# Introduce `cell`

## Summary

This RFC introduces a new tracking primitive, which represents a single value, the `Cell`.

## Motivation

The `Cell` is part of the "spreadsheet analogy" when talking about reactivity -- it represents a single tracked value, and can be created without the use of a class, making it a primate candidate for demos[^demos] and for creating reactive values in function-based APIs, such as _helpers_, _modifiers_, or _resources_. They also provide a benefit in testing as well, since tests tend to want to work with some state, the `Cell` is wholly encapsulated, and can be quickly created with 0 ceremony.

This is not too dissimilar to the [Tracked Storage Primitive in RFC#669](https://github.com/emberjs/rfcs/blob/master/text/0669-tracked-storage-primitive.md). The `Cell` provides more ergonomic benefits as it doesn't require 3 imports to use.
Comment thread
NullVoxPopuli marked this conversation as resolved.
Outdated

The `Cell` was prototyped in [Starbeam](https://starbeamjs.com/guides/fundamentals/cells.html) and has been available for folks to try out in ember via [ember-resources](https://github.com/NullVoxPopuli/ember-resources/tree/main/docs/docs).

[^demos]: demos _must_ over simplify to bring attention to a specific concept. Too much syntax getting in the way easily distracts from what is trying to be demoed. This has benefits for actual app development as well though, as we're, by focusing on concise demo-ability, gradually removing the amount of typing needed to create features.

## Detailed design

> [!NOTE]
> Only `cell` will be importable as a value. The types will also be importable, but the `Cell` class is private.

### Types

Some interfaces to share with future low-level reactive primitives:

~~~ts
interface Reactive<Value> {
/**
* The underlying value
*
* Allows easy usage of reactive values in templates.
*
* @example
*
* ```gjs
* const myCell = cell(0);
* <template>
* {{myCell.current}}
* </template>
* ```
*/
current: Value;

/**
* Returns the underlying value
*/
read(): Value;
Comment thread
NullVoxPopuli marked this conversation as resolved.
Outdated
}

interface ReadOnlyReactive<Value> extends Reactive<Value> {
/**
* The underlying value.
* Cannot be set.
*/
readonly current: Value;

/**
* Returns the underlying value
*/
read(): Value;
}

/**
* Utility to create a Cell without the caller using the `new` keyword.
* exists as a separate function so that in memory, there is only one copy of the Cell class,
* with a single constructor.
*/
function cell<Value>(
initialValue: Value,
options?: {
equals: (a: Value, b: Value) => boolean,
description?: string
} = {}
) {
return new Cell(
initialValue,
{
equals: options?.equals ?? Object.is,
description: options?.description
}
);
}

interface Cell<Value> extends Reactive<Value> {
constructor(
initialValue: Value,
options: {
equals: (a: Value, b: Value) => boolean;
description: string
}): {}

/**
* Function short-hand of updating the current value
Comment thread
NullVoxPopuli marked this conversation as resolved.
* of the Cell
*/
set: (value: Value) => boolean;
Comment thread
NullVoxPopuli marked this conversation as resolved.
/**
* Function short-hand for using the current value to
* update the state of the Cell
Comment thread
NullVoxPopuli marked this conversation as resolved.
*/
update: (fn: (value: Value) => Value) => void;

/**
* Prevents further updates, making the Cell
Comment thread
NullVoxPopuli marked this conversation as resolved.
* behave as a ReadOnlyReactive
*/
freeze: () => void;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

For "feature parity" with Object.freeze, would it make sense to also have isFrozen(), as in https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/isFrozen?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't think we need to worry about checking this -- it's an optimization for caches'n' such

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Tho, if you or anyone can think of a good use case for isFrozen, maybe we add it?

}
~~~

Behaviorally, the `Cell` behaves almost the same as this function:
```js
function cell(initial, { equals, description ) = {}) {
return new CellPolyfill(initial, { equals, description });
}

class CellPolyfill {
#isFrozen = false;
#value;

constructor(initialValue, options) {
this.#value = initialValue;
this.#equals = options.equals;
this.#description = options.description;
// ...
}

get current() {
// + consume
return this.#value;
}

read() {
// + consume
return this.#value;
}

set(value) {
assert(`Cannot set a frozen Cell`, !this.#isFrozen);
// + dirty
this.#value = value;
}

update(updater) {
assert(`Cannot update a frozen Cell`, !this.#isFrozen);
// #value is not tracked,
// so update reads without consuming
this.set(updater(this.#value));
}

freeze() {
this.#isFrozen = true;
}
}
```

The key difference is that with a primitive, we expose a new way for developers to decide when their value becomes dirty.
The above example, and the default value, would use the "always dirty" behavior of `() => false`.

This default value allows the `Cell` to be the backing implementation if `@tracked`, as `@tracked` values do not have equalty checking to decide when to become dirty.

For example, with this Cell and equality function:

```gjs
const value = cell(0, { equals (a, b) => a === b });

const selfAssign = () => value.current = value.current;

<template>
<output>{{value}}</output>

<button {{on 'click' selfAssign}}>Click me</button>
</template>
```

The contents of the `output` element would never re-render due to the value never changing.

This differs from `@tracked`, as the contents of `output` would always re-render.


### Usage

Incrementing a count with local state.

```gjs
import { cell } from '@glimmer/tracking';

const increment = (c) => c.current++;

<template>
{{#let (cell @initialCount) as |count|}}
Count is: {{count.current}}

<button {{on "click" (fn increment count)}}>add one</button>
{{/let}}
</template>
```

Incrementing a count with module state.
This is already common in demos.

```gjs
import { cell } from '@glimmer/tracking';

const count = cell(0);
const increment => count.current++;

<template>
Count is: {{count.current}}

<button {{on "click" increment}}>add one</button>
</template>
```

Using private mutable properties providing public read-only access:

```gjs
export class MyAPI {
#state = cell(0);

get myValue() {
return this.#state;
}

doTheThing() {
this.#state = secretFunctionFromSomewhere();
}
}
```


### Re-implementing `@tracked`

For most current ember projects, using the TC39 Stage 1 implementation of decorators:

```js
function tracked(target, key, { initializer }) {
let cells = new WeakMap();

function getCell(obj) {
let cell = cells.get(obj);

if (cell === undefined) {
cell = new Cell(initializer.call(this), { equals: null, description: `tracked:${key}` });
cells.set(this, cell);
}

return cell;
};

return {
get() {
return getCell(this).read();
},

set(value) {
getCell(this).set(value);
},
};
}
```

<details><summary>Using spec / standards-decorators</summary>

```js
import { Cell } from '@glimmer/tracking';

export function tracked(target, context) {
const { get } = target;

return {
get() {
return get.call(this).read();
},

set(value) {
get.call(this).set(value);
},

init(value) {
return new Cell(value, { equals: null, description: `tracked:${key}` });
},
};
}
```

</details>


## How we teach this

The `Cell` is a primitive, and for most real applications, folks should continue to use classes, with `@tracked`, as the combination of classes with decorators provide unparalleled ergonomics in state management.

However, developers may think of `@tracked` (or decorators in general) as magic -- we can utilize `Cell` as a storytelling tool to demystify how `@tracked` works -- since `Cell` will be public API, we can easily explain how `Cell` is used to _create the `@tracked` decorator_.

We can even use the example over-simplified implementation of `@tracked` from the _Detailed Design_ section above.


## Drawbacks

- another API

## Alternatives

- Have the cell's equality function check value-equality of primitives, rather than _always_ dirty. This may mean that folks apps could subtly break if we changed the `@tracked` implementation. But we could maybe provide a different tracked implementation from a different import, if we want to pursue this equality checking without breaking folks existing apps.

## Unresolved questions

- none yet