Is it OK to use a signal as component Input in Angular?

7.5k Views Asked by At

In the following mock-up I use a signal as an input to a component. It works. But I am wondering if this is the proper use of a signal. Seems....weird.

The follwing is a service that maintains a list of My Friends:

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

export interface MyFriend {
  name: string;
  age: number;
}

@Injectable({
  providedIn: 'root'
})
export class MyFriendsService {
  myFriends = signal < MyFriend[] > ([{
      name: 'Bob',
      age: 34,
    },
    {
      name: 'Julie',
      age: 30,
    },
    {
      name: 'Martin',
      age: 25,
    },
  ]);
}

The folowing is a Friend Detail Component that shows the detail of a particular friend:

import {
  Component,
  Input,
  computed,
  signal
} from '@angular/core';
import {
  MyFriend,
  MyFriendsService
} from './my-friends.service';

@Component({
  selector: 'app-friend-detail',
  templateUrl: './friend-detail-component.html',
})
export class FriendDetailComponent {
  @Input() name = signal('Bob');
  friendDetail = computed < MyFriend > (() => {
    const friend = this.myFriendsService
      .myFriends()
      .filter((friend) => friend.name === this.name());
    if (friend.length > 0) {
      return friend[0];
    }
    return {
      name: '',
      age: 0,
    };
  });
  constructor(private myFriendsService: MyFriendsService) {}

The following component simply uses the FriendDetailComponent:

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

@Component({
  selector: 'app-some-component',
  template: `<app-friend-detail [name]="selectedName"></app-friend-detail>`,
})
export class SomeComponent {
  selectedName = signal < string > ('Julie');
}

As I said, this WORKS. But...is this a proper way to be using a signal in angular?

FYI, I realize that the FriendDetailComponent should be more "dumb" and just accept the input of the FriendDetail info (it need not use signals)...but I did this mock-up as a way to explain a pattern I fell into within the context of a more complex situation.

3

There are 3 best solutions below

0
On

[EDIT] since 17.1 you can now use a signal as input. There is no need for @Input() anymore:

firstName = input<string>(); //InputSignal<string|undefined>

Previous answer below:

It's not yet ready, you can however convert a regular input into a signal one using this helper:

//signal-property.helper.ts
import { signal, Signal } from '@angular/core';

const cacheMap = Symbol('Cached values');

function getObservableMap<T extends Object, K extends keyof T>(
    obj: T
): Map<K, Signal<T[K]>> {
    // upsert the cacheMap
    return ((obj as any)[cacheMap] =
        (obj as any)[cacheMap] ?? new Map<any, any>());
}

function isUsingAccessor<T extends Object, K extends keyof T>(
    obj: T,
    variable: K
): boolean {
    // the prototype of the object (and not the own property) is holding information about the object getters at the time of the construction
    const prototypeOfObject = Object.getPrototypeOf(obj);
    const descriptorOfPrototype = Object.getOwnPropertyDescriptor(
        prototypeOfObject,
        variable
    );
    return (
        !!descriptorOfPrototype &&
        ('get' in descriptorOfPrototype || 'set' in descriptorOfPrototype)
    );
}

function checkIsNotAccessor<T extends Object, K extends keyof T>(
    obj: T,
    variable: K
): void {
    if (isUsingAccessor(obj, variable)) {
        throw new Error('Listening value accessors is not supported');
    }
}

function createSignalProperty<T extends Object, K extends keyof T>(
    obj: T,
    variable: K
): Signal<T[K]> {
    const defaultDescriptor: PropertyDescriptor =
        Object.getOwnPropertyDescriptor(obj, variable) ??
        defaultPropertyDescriptor();
    const aSignal = signal<T[K]>(undefined as any);

    checkIsNotAccessor(obj, variable);

    const { enumerable, configurable } = defaultDescriptor;
    const descriptor: PropertyDescriptor = {
        configurable,
        enumerable,
        get: () => defaultDescriptor.value,
        set: (nextValue) => {
            defaultDescriptor.value = nextValue;
            aSignal.set(nextValue);
        },
    };

    const isValueAlreadyDeclared = 'value' in defaultDescriptor;
    if (isValueAlreadyDeclared) {
        aSignal.set(defaultDescriptor.value);
    }

    Object.defineProperty(obj, variable, descriptor);

    return aSignal.asReadonly();
}

function defaultPropertyDescriptor(): PropertyDescriptor {
    return {
        configurable: true,
        enumerable: true,
        writable: true,
    };
}

export function asSignal<T extends Object, K extends keyof T>(
    obj: T,
    property: K
): Signal<T[K]> {
    const map = getObservableMap(obj);

    if (!map.has(property)) {
        map.set(property, createSignalProperty(obj, property));
    }
    // casting is mandatory
    return map.get(property)! as Signal<T[K]>;
}

usage is as follows:

import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { asSignal } from '../../../shared/helpers/signal/signal-property.helper';

@Component({
    selector: 'app-sub-test',
    standalone: true,
    imports: [CommonModule],
    template: '{{valueSignal()}}', // print the signal
    styleUrls: ['./sub-test.component.scss'],
    changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SubTestComponent {
    @Input()
    value!: string;
    valueSignal = asSignal(this, 'value'); // here is a signal of value
}
0
On
Edit: model inputs available since Angular 17.2

Phase 2 (or maybe Phase 3) of Signals will introduce signal: true components, which will include two-way bindings.

The RFC https://github.com/angular/angular/discussions/49682 describes this under Model inputs.

In short (and API is subject to change) you will be able to define a special type of input that can be manipulated as a signal but will be a two way binding back to the parent.

But you won't be passing a signal directly, it will just appear as a signal inside the component.

If you're trying to pass signals to a component today you can do that via a service or just a normal input which is set to the evaluated signal value.

0
On

Yes, it's out just now, in V 17.1.0 RC0