Angular Signals

Redux

RxJs

MobX

NgRx

MobX

SolidJS

MobX = Proxy + Signals

import { observable, computed, action, flow } from "mobx"

class Doubler {
    @observable accessor value

    constructor(value) {
        this.value = value
    }

    @computed
    get double() {
        return this.value * 2
    }

    @action
    increment() {
        this.value++
    }

    @flow
    *fetch() {
        const response = yield fetch("/api/value")
        this.value = response.json()
    }
}
autorun(() => {
    console.log(doubler.double);
})

SolidJS

import { createSignal, onCleanup } from "solid-js";

import { render } from "solid-js/web";

 

const CountingComponent = (props) => {

    const [count, setCount] = createSignal(0);

 

    const interval = setInterval(

        () => setCount(c => c + 1),

        1000

    );

 

    onCleanup(() => clearInterval(interval));

   

    return (

        <div>

            <h2>{props.appName}</h2>

            <div>Count value is {count()}</div>

        </div>

    );

};

 

render(() => <CountingComponent appName={"Counter APP"} />, document.getElementById("app"));

granular updates!

Примитивы - signal

import { signal } from '@angular/core';

const count = signal(0);

console.log('The count is: ' + count());      // The count is: 0
count.set(1); /* or */ count.update(v => v++);

console.log('The count is: ' + count());      // The count is: 1

Примитивы - computed

import { signal, computed } from '@angular/core';

const count = signal(0);              // WritableSignal<number>
const showCount = signal(false);      // WritableSignal<boolean>

const countDisplay = computed(() => { // Signal<string>
  if (showCount()) {
    return `The count is ${count()}`;
  } else {
    return 'Counter is hidden';
  }
});
 

console.log(countDisplay());          // Counter is hidden

showCount.set(true);

console.log(countDisplay());          // The count is 0

Примитивы - effect

import { signal, computed, effect } from '@angular/core';

 

const count = signal(0);
const showCount = signal(false);

const countDisplay = computed(() => {
  if (showCount()) {
    return `The count is: ${count()}`;
  } else {
    return 'Counter is hidden';
  }
});
effect(() => {
  console.log(countDisplay());
}, { injector, manualCleanup: true, allowSignalWrites: false });

showCount.set(true); // The count is: 0

Примитивы - effect

import { signal, computed, effect } from '@angular/core';

 

const count = signal(0);
const showCount = signal(false);

const countDisplay = computed(() => {
  if (showCount()) {
    return `The count is: ${count()}`;
  } else {
    return 'Counter is hidden';
  }
});
const effectRef = effect(() => {
  console.log(countDisplay());
}, { injector, manualCleanup: true, allowSignalWrites: true });

showCount.set(true);
showCount.set(false);
showCount.set(true);
effectRef.destroy(); // ?

Как работает

const count = signal(0);
const showCount = signal(false);

const countDisplay = computed(() => {
  if (showCount()) {
    return `The count is: ${count()}`;
  } else {
    return 'Counter is hidden';
  }
});

effect(() => {
  console.log(countDisplay());
});

queueMicrotask(() => showCount.set(true));

Reactive Graph

Watch Node

effect

activeConsumer

Как работает

Reactive Graph

Computed Node

countDisplay 

Watch Node

effect

activeConsumer

countDisplay.consumers = [effect]

effect.producers= [countDisplay]

const count = signal(0);
const showCount = signal(false);

const countDisplay = computed(() => {
  if (showCount()) {
    return `The count is: ${count()}`;
  } else {
    return 'Counter is hidden';
  }
});

effect(() => {
  console.log(countDisplay());
});

queueMicrotask(() => showCount.set(true));

Как работает

Reactive Graph

Signal Node

showCount 

Computed Node

countDisplay 

Watch Node

effect

activeConsumer

countDisplay.producers= [count, showCount]

showCount.consumers = [countDisplay]

const count = signal(0);
const showCount = signal(false);

const countDisplay = computed(() => {
  if (showCount()) {
    return `The count is: ${count()}`;
  } else {
    return 'Counter is hidden';
  }
});

effect(() => {
  console.log(countDisplay());
});

queueMicrotask(() => showCount.set(true));

Как работает

Reactive Graph

Signal Node

showCount 

Computed Node

countDisplay 

Watch Node

effect
const count = signal(0);
const showCount = signal(false);

const countDisplay = computed(() => {
  if (showCount()) {
    return `The count is: ${count()}`;
  } else {
    return 'Counter is hidden';
  }
});

effect(() => {
  console.log(countDisplay());
});

queueMicrotask(() => showCount.set(true));

 // Counter is hidden

Как работает

Reactive Graph

Signal Node

showCount 

Computed Node

countDisplay 

Watch Node

effect
const count = signal(0);
const showCount = signal(false);

const countDisplay = computed(() => {
  if (showCount()) {
    return `The count is: ${count()}`;
  } else {
    return 'Counter is hidden';
  }
});

effect(() => {
  console.log(countDisplay());
});

queueMicrotask(() => showCount.set(true));

showCount.consumers.forEach((c) => c.update())

live

live

countDisplay.producersStates.showCount.version ++

countDisplay.producers = []

countDisplay.run()

Как работает

Reactive Graph

Signal Node

showCount 

Computed Node

countDisplay 

Watch Node

effect
const count = signal(0);
const showCount = signal(false);

const countDisplay = computed(() => {
  if (showCount()) {
    return `The count is: ${count()}`;
  } else {
    return 'Counter is hidden';
  }
});

effect(() => {
  console.log(countDisplay());
});

queueMicrotask(() => showCount.set(true));

Signal Node

count 

countDisplay.consumers.forEach((c) => c.update())

effect.producersStates.countDisplay.version ++

effect.run()

Как работает

Reactive Graph

Signal Node

showCount 

Computed Node

countDisplay 

Watch Node

effect
const count = signal(0);
const showCount = signal(false);

const countDisplay = computed(() => {
  if (showCount()) {
    return `The count is: ${count()}`;
  } else {
    return 'Counter is hidden';
  }
});

effect(() => {
  console.log(countDisplay());
});

queueMicrotask(() => showCount.set(true));

Signal Node

count 

 // The count is 0

Angular -> signals

import {Component, input, model, ChangeDetectionStrategy} from '@angular/core';
@Component({
  selector: '...',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>First name: {{firstName()}}</p>
    <p>Last name: {{lastName()}}</p>
  `
})
export class MyComp {
  // optional
  firstName = input<string>();           // InputSignal<string|undefined>

  // required
  lastName = input.required<string>();   // InputSignal<string>

  // alias
  age = input(0, {alias: 'studentAge'}); // InputSignal<number>

  //transform
  disabled = input(false, {              // InputSignalWithTransform<boolean, string | boolean>
    transform: (value: boolean|string) => typeof value === 'string' ? value === '' : value,
  });

  checked = model(false);                // ModelSignal<boolean> Two-way binding!

  toggle() {
    this.checked.set(!this.checked());
  }
}

RxJs -> signals

const count$ = interval(1000);

const count = toSignal(count$, { initialValue, injector, manualCleanup, rejectErrors, requireSync });

const count2$ = toObservable(count, { injector });

Рефакторинг

@LazyGetter()
get size$(): Observable<Size> {
  return combineLatest([this.grid.zoomed$, this.grid.ejected$]).pipe(
    switchMap(([zoomed, ejected]) => {
      if (zoomed || ejected) return of('full' as Size);
      return fromResize(this.el.nativeElement).pipe(
        map((size): Size => {
          if (Math.min(size.contentRect.width, size.contentRect.height) < 308) {
            return 'compact';
          } else {
            return 'collapsed';
          }
        }),
      );
    }),
    distinctUntilChanged(),
    shareLastValue(),
  );
}

Было:

resizeInfo = toSignal(fromResize(this.el.nativeElement));

size = computed<Size>(() => {
  if (this.grid().zoomed() || this.grid().ejected()) return 'full';

  const resizeInfo = this.resizeInfo();

  if (resizeInfo && Math.min(resizeInfo.contentRect.width, resizeInfo.contentRect.height) < 308) {
    return 'compact';
  } else {
    return 'collapsed';
  }
});

Стало:

Рефакторинг

@LazyGetter()
get whiteboardName$() {
  return this.app$.pipe(switchMap((app) => app!.title$));
}
@LazyGetter()
get whiteboardLink$() {
  return this.app$.pipe(
    switchMap((app) => app?.linkData$ ?? of(undefined)),
    distinctUntilChanged(shallowEqual),
  );
}

Было:

whiteboardName = computed(() => this.app()?.title());

whiteboardLink = computed(() => this.app()?.linkData());

Стало:

Рефакторинг

  private _linkData$ = new BehaviorSubject<WhiteboardLink | undefined>(undefined);
  get linkData$(): Observable<WhiteboardLink | undefined> {
    return this._linkData$.pipe(distinctUntilChanged(shallowEqual));
  }
  get linkData(): WhiteboardLink | undefined {
    return this._linkData$.value;
  }
private _linkData = signal<WhiteboardLink | undefined>(undefined);
get linkData() {
  return this._linkData.asReadonly();
}

Было:

Стало:

Рефакторинг

Было:

@if ((size$ | async) === 'full') {
  @if (whiteboardName$ | async; as whiteboardName ) {
    <header>
      ...
    </header>
  }
  @if (whiteboardLink$ | async) {
    <dynamic-component
      ...
    />
  }
}
@if (size() === 'full') {
  @if (whiteboardName()) {
    <header>
      ...
    </header>
  }
  @if (whiteboardLink()) {
    <dynamic-component
      ...
    />
  }
}

Стало:

Рефакторинг

public buildAppGridApi(app: ConferenceApp): AppGridApi {
  const zoomed$ = this.zoomedGridElement$.pipe(map((zoomed) => zoomed?.id === app.id));

  return {
    zoomed$,
    ejected$: this.ejectedGridElement$.pipe(map((ejected) => ejected?.id === app.id)),
    fullscreen$: combineLatest([zoomed$, this.viewFullscreenMode$]).pipe(map((values) => values.every(Boolean))),

    ...
  };

Было:

public buildAppGridApi(app: ConferenceApp): AppGridApi {
  const zoomed = toSignal(this.zoomedGridElement$, { injector: this.injector });
  const ejectedGridElement = toSignal(this.ejectedGridElement$, { injector: this.injector });
  const viewFullscreenMode = toSignal(this.viewFullscreenMode$, { injector: this.injector });
  return {
    zoomed: computed(() => zoomed()?.id === app.id),

    ejected: computed(() => ejectedGridElement()?.id === app.id),

    fullscreen: computed(() => zoomed() && viewFullscreenMode()),

    ...
  };
}

Стало:

Рефакторинг

Стало:

@LazyGetter() get ejectedGridElement() {
  return toSignal(this.ejectedGridElement$, { injector: this.injector });
}
@LazyGetter() get zoomedGridElement() {
  return toSignal(this.zoomedGridElement$, { injector: this.injector });
}
@LazyGetter() get viewFullscreenMode() {
  return toSignal(this.viewFullscreenMode$, { injector: this.injector });
}

public buildAppGridApi(app: ConferenceApp): AppGridApi {
  const zoomed = computed(() => this.zoomedGridElement()?.id === app.id);
  return {
    zoomed,
    ejected: computed(() => this.ejectedGridElement()?.id === app.id),
    fullscreen: computed(() => zoomed() && this.viewFullscreenMode()),
    ...
  };
}

Надо договорится

  • Нейминг сигналов?
  • Сигналы - для синхронно-реактивного кода. Для асинхронного лучший вариант это rxJs
  • effect надо осторожно, для чего-то очень простого
// bad
effect(() => {
  this.menuOpened(); // подписка на app

  this.loading.set(true);
});

// ok
toObservable(this.menuOpened)
  .pipe(takeUntilDestroyed()) // вроде не обязательно
  .subscribe(() => {
    this.loading.set(true);
  });

Signals

By Andrey Osipov

Signals

  • 197