Skip to content
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

A new reactive primitive: Cell #1071

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Changes from all 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
296 changes: 296 additions & 0 deletions text/1071-cell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
---
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.

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


### Types

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

```ts

interface Reactive<Value> {
/**
* The underlying value
*/
current: Value;
/**
* Returns the underlying value
*/
read(): Value;
}

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

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

interface Cell<Value> extends Reactive<Value> {
/**
* Utility to create a Cell without the caller using the `new` keyword.
*/
static create<T>(initialValue: T, equals?: (a: T, b: T) => boolean): Cell<T>;

/**
* The constructor takes an optional initial value and optional equals function.
*/
constructor(initialValue?: Value, equals?: (a: Value, b: Value) => boolean): {}

/**
* Function short-hand of updating the current value
* of the Cell
*/
set: (value: Value) => boolean;
/**
* Function short-hand for using the current value to
* update the state of the Cell
*/
update: (fn: (value: Value) => Value) => void;

/**
* Prevents further updates, making the Cell
* behave as a ReadOnlyReactive
*/
freeze: () => void;
}
```

Behaviorally, the `Cell` behaves almost the same as this function:
```js
class CellPolyfill {
@tracked current;
#isFrozen = false;

constructor(initialValue) {
this.current = initialValue;
}

read() {
return this.current;
}

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

update(updater) {
assert(`Cannot update a frozen Cell`, !this.#isFrozen);
this.set(updater(this.read()));
}

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.create(0, (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 = (cell) => cell.current++;

<template>
{{#let (Cell.create @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.create(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.create(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 = Cell.create(initializer.call(this), () => false);
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 Cell.create(value);
},
};
}
```

</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

Loading