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

RFC: Angular SignalArray and SignalDict #1

Open
ducin opened this issue Feb 18, 2024 · 12 comments
Open

RFC: Angular SignalArray and SignalDict #1

ducin opened this issue Feb 18, 2024 · 12 comments

Comments

@ducin
Copy link
Owner

ducin commented Feb 18, 2024

👉 see full typescript playground for arrays

👉 see full typescript playground for objects

Motivation

  1. Missing reactive APIs for arrays, objects

SignalArrays are meant to make arrays reactive. Native arrays, being mutable and non-reactive, require being wrapped with computed calls e.g. to make them reactive in case of displaying in templates or re-running side effects.

Wrapping an array calculation with computed is a simple task. However, in big scale it can become tedious and, as Angular will introduce signal/zoneless components, reactive arrays make a great fit. Knowing and, first and foremost, having to adjust mutable JS APIs into reactive Angular APIs is an implementation detail that shall not be developer's responsibility. Especially in Signal Components

  1. Slight performance improvements

For big data sets, having one signal can affect performance (changes on ingle items make the entire signal notify about a change). Creating a collection (array/dict) of manually maintained smaller signals introduces boilerplate. This is where collection signals kick in.

Note that NGRX signal store does exactly this with its DeepSignals.

Scope

The scope of this RFC is the API design of native JS Array and Object equivalents.

Approach

key points:

  • SignalArray and SignalDict are still native angular signals. All computed/effects/templates/etc will work seamlessly - call them directly the same as you would with ordinary signals (no additional un-wrapping required)
  • both are DeepSignals (composition, perf)

SignalArray

Following interfaces illustrate a rather precise API proposal:

import { Signal, WritableSignal } from '@angular/core';

interface SignalArray<T> extends Signal<T[]> {
  [idx: number]: WritableSignal<T>;

  at(idx: number): WritableSignal<T>;

  [Symbol.iterator](): IterableIterator<T>;

  length: Signal<number>;

  slice(start?: number, end?: number): SignalArray<T>;

  reverse(): SignalArray<T>;

  sort(compareFn: (a: T, b: T) => number): SignalArray<T>;

  with(index: number, value: T): SignalArray<T>;

  map<U>(mapperFn: (t: T) => U): SignalArray<U>;

  flatMap<U>(mapperFn: (t: T) => U[]): SignalArray<U>;

  filter(predicateFn: (t: T) => unknown): SignalArray<T>;
  
  find(predicateFn: (t: T) => unknown): Signal<T>;

  every(predicateFn: (t: T) => unknown): Signal<boolean>;

  some(predicateFn: (t: T) => unknown): Signal<boolean>;

  // skipping overloads for simplicity
  reduce<U>(reducerFn: (acc: U, t: T) => U, initial: U): Signal<U>;
}

interface WritableSignalArray<T> extends SignalArray<T> {
  // returns THE SAME signal, enables chaining/fluent API
  push(...items: T[]): WritableSignalArray<T>;
  // returns THE SAME signal, enables chaining/fluent API
  unshift(...items: T[]): WritableSignalArray<T>;
  pop(): T
  shift(): T

and following usage examples describe the characteristics:

interface Person {
  id: number
  name: string
  languages: string[]
}

declare const people: WritableSignalArray<Person>

1. SignalArray is a signal itself

const itemsToIterateOver = people() // Person[]

const eagerIteration = [...people] // Person[]

2. each item is a signal as well (slight performance improvement)

const firstPerson = people[0]
firstPerson.update(p => ({...p, languages: [...p.languages, 'JAVA 😈']}))
people.at(-1).update(p => ({...p, languages: [...p.languages, 'JAVA 😈']}))

3. all methods return computed signals

const len = people.length // Signal<number>
const lenInsideTemplate = len() // number

const allPeopleKnowAtLeastOneLanguage = people.every(p => p.languages.length > 0) // Signal<boolean>

4. all array-processing methods try to resemble Array API AMAP for convenience

const sortedLabels = people
  .sort((p1, p2) => p1.languages.length - p2.languages.length)
  .map(p => `${p.name}: ${p.languages.join(', ')}`)
  // SignalArray<string>, can be further processed with SignalArray API

const sortedLabelsCount = sortedLabels.length // Signal<number>

5. mutations - NOT signals

modifying the SignalArray changes its state, never return a new signal (equivalent to signa.update)

declare const person: Person
const theSameSignal = people.push(person, person, person) // WritableSignalArray<Person>

const chainedButTheSameSignalAllTheTime = people
  .push(person)
  .push(person)
  .push(person)
  //  // WritableSignalArray<Person>

const removedItem = people.pop() // Person

SignalDict

Following interfaces illustrate a rather precise API proposal. Note that - similar with static Object methods which use Array methods - some of SignalDict methods use SignalArray methods too:

interface SignalDict<K extends PropertyKey, T> extends Signal<Record<K, T>> {
  keys(): SignalArray<K> // `Object.keys(obj)` equivalent
  
  values(): SignalArray<T> // `Object.values(obj)` equivalent
  
  entries(): SignalArray<[K, T]> // `Object.values(obj)` equivalent

  at(key: K): WritableSignal<T> // `obj[key]` equivalent
  
  has(key: K): Signal<boolean> // `key in obj` equivalent

  add<Q extends PropertyKey>(key: Q, t: T): SignalDict<K | Q, T> // `delete obj[key]` equivalent
  
  delete(key: K): T // `delete obj[key]` equivalent
}

and usage

```ts
interface Person {
  id: number
  name: string
  languages: string[]
}

declare const person: Person

// non-discriminant property set
declare const unknownKeysPeople: SignalDict<string, Person>

// discriminant property set
declare const strictKeysPeople: SignalDict<'Alice' | 'Bob' | 'Charlie', Person>

1. all object-processing methods try to resemble Object API

1.1 items

const unknownKeysHaveDan = unknownKeysPeople.has('Dan') // Signal<boolean>

const strictKeysHaveDan = strictKeysPeople.has('Dan') // ❌ 👍 expected to fail
const strictKeysHaveBob = strictKeysPeople.has('Bob') // Signal<boolean>

1.2 collection - chainable with SignalArray

const peopleLabels = unknownKeysPeople
  .values()
  .filter(p => p.languages.length > 0)
  .map(p => `${p.name}: ${p.languages.join(', ')}`)
  // SignalArray<string>

const reducedBackToObject = strictKeysPeople
  .entries()
  .map(([key, value]) => [`Dear ${key}`, value] as const)
  .reduce((acc: { [key in (typeof key)] : typeof value}, [key, value]) => {
    type K = typeof key
    acc[key] = value
    return acc
  }, {} as any) // this type assertions is unavoidable within a chained call
  // Signal<{ "Dear Alice": Person; "Dear Bob": Person; "Dear Charlie": Person; }>

2. mutations - NOT signals

modifying the SignalArray changes its state, never return a new signal

2.1 modifications

unknownKeysPeople.at('Sebix').update(p => ({...p, languages: [...p.languages, 'JAVA 😈']}))

2.2 insertions

const unknownKeysWithDan = unknownKeysPeople.add('Dan', person) // SignalDict<string, Person>

const strictKeysWithDan = strictKeysPeople.add('Dan', person) // SignalDict<"Alice" | "Bob" | "Charlie" | "Dan", Person>

2.3 removals

const removed = unknownKeysPeople.delete('Sebix')
unknownKeysPeople; // Person
// still unknownKeysPeople: SignalDict<string, Person>

// same with strictKeysPeople due to `delete(key: K): T`
// but if `delete` implemented chaining/fluent API and returned the signal, it could have the chosen key removed:
// delete<Q extends K>(key: Q): SignalDict<Exclude<K, Q>, T>
@manfredsteyer
Copy link

I like the idea, especially having a Signal-based Array type.

Instead of SignalArray and SignalDict I'd call it ArraySignal and DictSignal (or RecordSignal to align with TypeScript's Record type) so that it fits to other types we got with Angular 17.2 like ModelSignal and InputSignal.

@sp90
Copy link

sp90 commented Feb 19, 2024

I think i like writing this code havn't tried so can't tell.

But i feel we're just replacing all the methods we had in rxjs with signal ones, and if we like rxjs why not just use rxjs?

I feel that signals are here to simplify the core of our reactive primitive. In extension to that there is a saying about programming languages: If the complexity is not in your language its in your code. I feel that applies to frontend frameworks as well (if the complexity is not in your framework its in your app).

This is also an abstraction on what is underneath so as a developer i need yet a layer of interpretation for the mind to comprehend one more thing to learn, to onboard new devs.

Anyway im not against you're efforts here i think it could bring value i just think, it's worth having a thought about is this really an improvement.

Much love!

@GabrielBB
Copy link

GabrielBB commented Feb 19, 2024

This is definitely nice. Is it worth it, tho ? 🤔 I'm not sure if computed(() -> ) is really tedious

@ducin
Copy link
Owner Author

ducin commented Feb 19, 2024

thanks for all the comments so far!

One thing to note that I forgot to mention in motivation (fixed already): for big data sets, having one signal can affect performance (changes on ingle items make the entire signal notify about a change). Creating a collection (array/dict) of manually maintained smaller signals introduces boilerplate. This is where collection signals kick in.

@manfredsteyer 👌 makes sense

@sp90 I definitely get your point and agree! The thing is - there's no reactive primitive in the language (JS), so if the framework brings a new reactive primitive (signal) but doesn't cover the most important APIs (Object, Array) then people would have to wrap them over and over and over again. And that's my point here.
Whether that's re-doing rxjs operators or not - it depends on how we name things, but yeah, one could say so.

If the complexity is not in your language its in your code

💯 !

one more thing to learn [...]

sure. But there are already redundant things in the framework (as directives vs component composition, pipes vs just service methods, or even NgModules which are being completely withdrawn from) which are completely redundant, yet, some people find them useful anyway.

Let's look at what we have now (no array signal, no dict signal etc.): one has to learn array/object API and one needs to learn signals and one needs to track whether something is a static value (number, object etc) or a reactive value (number signal, object signal etc.). Your respomnsibility (now) is to do the mapping everywhere. The point of this RFC to automate the non-reactive-to-reactive.

@GabrielBB yeah, that's THE RIGHT question :) what would you prefer:

  • to cover the native APIs with computed calls everywhere
  • have a slight wrapper which do it for you

@OmerGronich
Copy link

OmerGronich commented Feb 19, 2024

This proposal is great and would significantly enhance the signal DX.
Personally, I would prefer a unified API for both arrays and objects, similar to Solid Stores, for several reasons:

  • The core API is very minimal, which makes it easier to learn. (And maintain 😉)
    example:
const [person, setPerson] = createStore({
    firstName: "John",
    lastName: "Doe",
    get fullName() {
      return `${this.firstName} ${this.lastName}` // <- getters are like computeds :D 
    }
    age: 30,
    address: {
        street: "123 Main St",
        city: "Anytown",
        zipCode: "12345",
        country: "USA"
    }
};
);

// read value
person.firstName

// set entire store
setState({
    firstName: "Jane",
    lastName: "Doe",
    age: 25,
    address: {
        street: "500 Main St",
        city: "Anytown",
        zipCode: "54321",
        country: "USA"
    }
})

// set nested value
setState("address", "street", "456 Elm St") // changed Jane Doe's street to 456 Elm St
  • This can be implemented in your examples too, but still worth mentioning: in Solid Stores, signals are created on demand. This means not all properties of the object/array are eagerly turned into signals but rather signals are created when they are read inside a reactive context (effect)

  • Solid also provides an immer inspired utility that can help with immutable updates:

setState(
  produce((state) => {
    state.user.name = "Jane";
    state.list.push("pencil");
  })
);

@samuelfernandez
Copy link

This looks great! I was thinking on solutions to the same problem you describe for a project of my own, happy to see work on a powerful solution like the one you propose. Some thoughts:

  • For the signal array, I’d suggest that signals for specific items like when using at or [] are lazily created, instead of upfront. This could be applied to other properties as well. The reason is that consumers might not use all APIs, and making them lazy can save memory and processing on instantiation.
  • One of the things I really like from iterables in contrast with arrays is that map or filter functions are lazy evaluated, reducing materializations and memory footprint. It would be awesome that the signals you expose after combining operators are lazy in evaluation as well, and use the iterator version internally instead of creating intermediate arrays.
  • What you describe as reactive object or dictionary looks more like a native Map than an object itself. For a simple object that has a property like person.name I’d expect it’s reactive equivalent to be person.name() rather than person.at(‘name’). And similar when adding properties… I feel that API is more natural. You can implement it with proxies for example. Your proposal still has much value as a map signal. Properties could have the same lazy behavior as described for arrays.
  • The RFC does not speak about instantiation. I’d propose functions that can create those entities like the signal function. In addition, I’d recommend another one that takes as an argument a writable signal for an object/array and keeps the state in sync. The reason is that a common use case would be having a component with an input, a writable signal, and then exposing its properties internally through a reactive object/array.
  • Thanks for exposing all return types as deep read only. That is key piece I find missing both in Angular and NgRx.

Again, thanks so much for this well rounded RFC!

@JeanMeche
Copy link

JeanMeche commented Feb 19, 2024

While I would welcome a Signal array, I would go in the opposite direction :

interface SignalArray<T> extends Signal<T[]> {
   updateAtIndex(item:T): void
   push(item:T): void
   remove(index:number);
}

Those 3 methods would make the signal as dirty.

What I don't grasp is why you would add all those methods that are already available on arrays.
These would be easily replaced by a computed(() => mySignalArray().map(...) ). This way would keep the API surface as simple as possible.

Also note: Methods are not removed by tree shaking, so the less we have the better.

@alxhub
Copy link

alxhub commented Feb 19, 2024

I'm pretty interested in these kinds of explorations! I think the SignalArray concept here is missing a key feature: we want to be able to distinguish on the consumer side between mutations to the array structure (adding/removing items) and mutations to individual items. That is, arr.push(newItem) shouldn't cause any individual item signals to update, and arr[0].set(newValue) shouldn't cause the array structure to update. In practice:

  • ArraySignal should be a Signal<Array<Signal<T>>>.

This decouples mutations of individual items from structural changes. The outer signal only fires (with a new array instance) when items are added or removed from the array itself. Because each item is a signal, the identity of items remains stable even when the values inside can be mutated.

  • It should be possible to get ahold of an individual item as a WritableSignal<T>.

Your .at does this, but I'm not sure imitating the array methods is a good idea. As @JeanMeche says, most of these operations can be done via computed anyway.

This allows for updating an individual item without causing the whole array signal to recompute.

  • It should be straightforward to push/pop/otherwise mutate the array.

The main value of ArraySignal over just Signal<Array<Signal<T>>> is in the convenience of managing the "wrapped" inner signals for you. Making it so the user doesn't have to think about creating/managing the inner WritableSignals is key.

@samuelfernandez
Copy link

@alxhub I would argue that if an individual item is updated, any consumers of the array should be notified. Since change detection of computed relies on identity, that could only be achieved with immutability. As with anything, it will depend on the use case, but I feel that the behavior described in this RFC makes sense as a default.

@alxhub
Copy link

alxhub commented Feb 20, 2024

In some use cases, the array structure is watched but changes to the values themselves aren't relevant. One such use case is perhaps the most critical in the framework - repeating UI with@for.

At a high level, you can think of @for as an effect() that creates/destroys views as changes happen within an array. @for watches a signal for the array, and runs a diffing algorithm to figure out what's structurally changed in the array. This diffing algorithm may account for immutable data using a track expression to compute keys.

Diffing for changes is fast, but it's still an O(n) operation and not one we want to run frequently. Therefore, it's incredibly beneficial for @for if the signal of the array instance itself does not change if only values are updated. This ensures that diffing will only run when the array structure changes, and new views/rows need to be created or destroyed. Updates to individual items will affect only that view/row and won't trigger diffing, which is optimal.

The nice thing is that exposing the elements as separate signals has perfect behavior for when you do want to watch both the array and each item. For example, you could re-implement Array.every:

function every<T>(arr: ArraySignal<T>, predicate: (value: T) => boolean): boolean {
  for (const value of arr()) {
    if (!predicate(value()) {
      return false;
    }
  }
  return true;
}

Using this function inside a reactive context (like a computed, effect, or template) will watch the array signal, plus only the elements needed to actually compute the answer. If the predicate is false for element 3, elements 4+ won't even be watched. Changing them won't cause the effect to rerun or the template to be change detected, because they can't affect the overall answer.

@DmitryEfimenko
Copy link

soooo.... do it?

@gregoriusus
Copy link

Yes, we need this badly

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

No branches or pull requests

10 participants