Signals the new kid on the block, it is literally a game changer by changing how applications render and re-redner data in components. But how far can we go ? lets check out how far we can take this early stage solution and see its limitations. We will be looking at both React and Angular (master, not the angular-signal branch)
TLDR: Yes Storing simple React components inside A Signal is possible. and even changing them in runtime works as expected. nothing special.
const Comp1 = () => {
return <>Hello world comp 1</>
}
const Comp2 = () => {
return <>Hello world comp 2</>
}
let signalComponent = signal(Comp1)
function App() {
return (
<div className="App">
{signalComponent.value()}
<button onClick={() => signalComponent.value = Comp2}>Switch</button>
</div>
);
}
export default App;
Since a component is a function and we want to render the UI only on demand, we will trigger the component function in the html. notice how we access the .value in order to register it as a listener and enjoy the change detection changes.
TLDR: No…. its complex
Hooks are triggered as life cycle event, changing the value of the signal doesn’t trigger it unfortunately
function App() {
const Comp1 = () => {
useEffect(() => {
console.log("in 1")
}, [])
return <>Hello world comp 1</>
}
const Comp2 = () => {
useEffect(() => {
console.log("in 2")
}, [])
return <>Hello world comp 2</>
}
// eslint-disable-next-line react-hooks/rules-of-hooks
let signalComponent = useSignal(Comp1)
return (
<div className="App">
{signalComponent.value()}
<button onClick={() => signalComponent.value = Comp2}>Switch</button>
</div>
);
}
export default App;
In the example above, the first console.log(“in 1”) will trigger as a part of the initial React render. but changing the value to Comp2 will not trigger the the useEffect hook of the Comp2. (I found this disappointing but pretty expectable). If you want to manually run the change detection function than yes, it will work…
So how do we do achieve it ? like the code block below, move the Comp components outside of the main component and render a component by the value of the signal (note that using a signal as a depdency for a hook won’t work.
const Comp1 = () => {
useEffect(() => {
console.log("in 1")
}, [])
return <>Hello world comp 1</>
}
const Comp2 = () => {
useEffect(() => {
console.log("in 2")
}, [])
return <>Hello world comp 2</>
}
let signalComponent = signal(true)
function App() {
return (
<div className="App">
signalComponent: {`${signalComponent}`} <br/>
{signalComponent.value ? <Comp1 /> : <Comp2 />}
<button onClick={() => signalComponent.value = false}>Switch</button>
</div>
);
}
export default App;
@Component({
selector: 'app-root',
template: `
<div>
value is: {{aValue()}}
<button (click)="onClick()" >Switch</button>
</div>
`,
styleUrls: ['./app.component.scss'],
})
export class AppComponent {
constructor() {
}
a = signal(2); <-- initiate the signal
aValue = () => computed(() => { <-- this is a must !!
return this.a.value
})
onClick() {
this.a.value++; <-- update the signal
}
}
Angular runs it re-render function when it detects something internally has changed or something happened that it guess will require a re-render. Due to how Angular interacts between the “html” and the javascript we need to initiate the computed function. See how the html directly runs the aValue as a function, if not it will not update on click. This entire hacky solution is pretty funny, but lets keep going. We can see that Angular is very strict about what triggers its change detection. while is causes less re-renders it makes us use funny code to make Signals work. I hated how this code looked, having a function call from the html in Angular is a big no no (horrible performance). So what do we do ? continue doing hacky things to try and make something out of it. I wanted to make it seamless “Angular” interaction with the access to the Signal.
@Component({
selector: 'app-root',
template: `
<div>
value {{a?.value}}
<button (click)="onClick()" >Switch</button>
</div>
`,
styleUrls: ['./app.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush <-- on push, which means no automatic refresh
})
export class AppComponent implements OnInit{
a: any
constructor() {
this.a = new Object();
this.a._signal = null <-- empty initializer
Object.defineProperty(this.a,"value", { <-- this is the magical part
get() {
if(this.a._signal) { <-- if the signal is initiated return its value
return computed(() => this.a._signal.value);
} else {
return null
}
},
set(value: any) {
if(this.a?._signal) { <-- if singal initated sets its value
this.a._signal.value = value
} else { <-- if not , initiate a new signal
if(!this.a) this.a = {};
this.a._signal = signal(value);
}
}
})
}
ngOnInit() {
this.a.value = 2;
}
onClick() { <-- triggers the on change detection of angular
this.a.value++;
}
}
While not a generic solution it does what I wanted. remove Signal from the lexicon of the template but keep it internally (I wonder if I would make the effort to make it a generic function… The way its done actually provides us the ability to not need the change detection mechanism. This is the most POC thing ever made so obviously there are still many things to learn and do.
I truely belive in Signals to be the future of DOM manipulation and will create a unified pattern to create components. while the future is amazing, it currenly still needs many iterations and battle testing. There are many PRs open that come to improve this amazing project.