DEV Community

Cover image for Thinking Locally with Signals
Ryan Carniato for This is Learning

Posted on

Thinking Locally with Signals

As the creator of SolidJS, I was very influenced by React when designing the library. Despite what people might believe by looking at it, it wasn't the technology of Virtual DOM or JSX, but the principles that inspired me. Those technologies may showcase React's capability, and maybe even define it as a solution, but aren't its legacy.

Instead, it is things like unidirectional flow, composition, and explicit mutation, that continue to influence how we build user interfaces. As I watch the wave of adoption of Solid's core reactive technology, Signals, by libraries across the whole ecosystem it is vitally important we don't forget the lessons learned from React.


Locality of Thinking

Locally grown produce

The real magic of React is that when you combine all its design principles, the result is that you can reason about how a given component behaves without seeing the rest of the code.

This allows new people to be productive without knowing the whole code base. It allows features to be built in isolation without impacting the rest of the application. It lets you return to something that you wrote a year ago and understand what it is doing.

These are incredible powers to have for building software. Thankfully aren't limited to the technology choices made by React.


Passing Values Down

Component Graph from React Docs

Unidirectional Flow is probably the most important part of achieving locality of thinking. Generally, this starts with immutability as with passing something mutable you can never trust it after that call.

let obj = {}
someFunction(obj)

// Is this true?
console.log(obj.value === undefined)

// You can't tell without looking at the code for `someFunction`
Enter fullscreen mode Exit fullscreen mode

However, this does not require true immutability to pull off. It does require read/write segregation. Or in other words explicit mutation. The act of passing a value down should not implicitly give the ability to mutate it.

So any interface leaving our component whether it be for primitive composition (like custom Signals) or JSX should not by default pass both the value and the setter. We encourage this in SolidJS by using a pass-by-value approach in JSX and by providing primitives that by default are read/write separated.

// [read, write]
const [title, setTitle] = createSignal("title");

// `title()` is the value, `SomeComponent` can't change `title`
<SomeComponent title={title()} />

// Now `SomeComponent` can update it
<SomeComponent title={title()} updateTitle={setTitle} />
Enter fullscreen mode Exit fullscreen mode

Svelte Runes has taken another way to accomplish this by compiling their variable accesses to Signal reads. A variable can only be passed by value so there is no fear of it being written outside of the current scope.

let title = $state("title")

// `SomeComponent` can't change `title` that you see declared in this file
<SomeComponent title={title} />

// Now `SomeComponent` can update it
<SomeComponent title={title} updateTitle={(v) => title = v} />
Enter fullscreen mode Exit fullscreen mode

This mechanically is essentially the same Solid example when it gets compiled. In both cases, the Signal doesn't leave the component and the only way to mutate it is defined alongside its definition.


Receiving Values from Above

Image description

This pass-by-value approach is also beneficial for things coming into your component as well. Picture if you could pass Signals or values.

function SomeComponent(props) {
  createEffect(() => {
    // Do we call this as a function or not?
    document.title = props.title
  })
}
Enter fullscreen mode Exit fullscreen mode

We could always check:

document.title = isSignal(props.title) ? props.title() : props.title
Enter fullscreen mode Exit fullscreen mode

But picture having to do that everywhere for every prop you use in any component you ever author. SolidJS doesn't even ship with an isSignal to discourage this pattern.

As the component author you could force only Signals but that isn't ergonomic. Solid uses functions so maybe not a big deal, but picture if you are using Vue or Preact Signals that use .value. You wouldn't want to force people to:

<SomeComponent title={{value: "static title"}} />

// or unnecessary signal
const title = useSignal("static title")
<SomeComponent title={title} />
Enter fullscreen mode Exit fullscreen mode

I'm not being critical of those APIs here but emphasizing the importance of maintaining a pass-by-value API surface for props. For ergonomics and performance, you don't want users overwrapping.

The way to solve this is to provide the same interface for reactive and non-reactive values. Then mentally treat all props as being reactive if you need them to be. If you treat everything as reactive you don't have to worry about what happens above.

In the case of Solid reactive props are getters:

<SomeComponent title={title()} />

// becomes
SomeComponent({
  get title() { return title() }
})

// whereas
<SomeComponent title="static title" />

// becomes
SomeComponent({
  title: "static title"
})

// Inside our component we aren't worried about what is passed to us
function SomeComponent(props) {
  createEffect(() => {
    document.title = props.title
  })
}
Enter fullscreen mode Exit fullscreen mode

Using getters has an added benefit in that writing back to props doesn't update the value, enforcing the mutation control.

props.title = "new value";

console.log(props.title === "new value"); //false
Enter fullscreen mode Exit fullscreen mode

Limits to Locality of Thinking

Image description

While it might be the most valuable result of modern UI practices, locality of thinking isn't perfectly achieved in the tools we use today. UI components aren't all pure. They have state. While not necessarily having external side effects the fact that we preserve references in closures that can impact future executions means those executions do matter.

Even with following these principles, the one thing we can't control is how often our parent calls us. On one hand, we can think of our components as purely the output of our inputs and this keeps things simple. But sometimes when we hit performance issues it isn't what we are doing but something above and we are forced out of our local frame.

Paired with a model that doesn't encourage the parent to re-call us that often does go a long way. This is one of several contributing motivations to why frameworks are choosing Signals over VDOM re-renders. It isn't that Signals can completely avoid over-notification from parents, but that the impact is generally much smaller and it happens less often as the guards are much finer-grained and built into the model.


Wrapping Up

I've talked to many long-time React users who remember when these fine-grained reactive patterns went through their last cycle. They remember crazy cycles of butterfly effects like event notifications. But the reality today is that when we look at Signals those concerns have lost all substance.

It is much more like an evolution of the move to break things down into more controllable pieces.

Components -> Hooks -> Signals

But only if we stay to the same principles that were laid out in the first place in React. There is a reason why Solid doesn't have isSignal or Svelte Runes don't allow you to assign a Signal to a variable. We don't want you to worry about the data graph outside of your view.

Inside your local scope, there is no way to avoid it. JavaScript doesn't do automated granular updates, so even if we try to hide it with the best compiler imaginable with automated reactivity or memoization you need to have the language to make sense of what you are seeing.

The common ground is, that if you treat everything as reactive that could be, the burden of the decision of what is reactive is pushed up to the consumer, regardless of whether you dealing with simple Signals, nested Stores, primitives passed from props or coming from global singletons. Regardless of how heavily you rely on compilation for the solution.

The consumer, the owner of the state (or at least the one passing it down), is precisely the one who can make that decision. And if you give them the ability to think locally you unburden them by giving them the confidence that they can make the right one.

Top comments (16)

Collapse
 
aralroca profile image
Aral Roca

I would like to understand how the signals work to update the dom. The jsx-runtime transforms everything into DOM elements and if one has a Signal the update of the element is registered during the jsx-runtime ? And how is it solved if you use signals for conditional renders?

Collapse
 
ryansolid profile image
Ryan Carniato

In basic:

const elementsToInsert = createMemo(() => {
  return showSignal() ?
    createComponent(A, aProps) :
    createComponent(B, bProps)
})
Enter fullscreen mode Exit fullscreen mode

We transform everything to lazy evaluated expressions so we don't great the DOM elements up front. You can picture we wrap everything in functions.

I tried to explain this a couple years ago.. admin.indepth.dev/solidjs-reactivi...

I think this article isn't the clearest but I try to make it consumable.

Collapse
 
aralroca profile image
Aral Roca • Edited

I figure out reactivity with conditional renders only if are inside a node. I realized one thing that I don't get out of, and that is when there is reactivity mixed with static things. Example:

<div>
  <b>Hello </b>
  {signal() ? 'World' : <><i>and</i> bye</>}
</div>
Enter fullscreen mode Exit fullscreen mode

What would this JSX look like in HyperScript?

h('div', {}, [
  h('b', {}, 'Hello '),
  h(null, {}, () => signal() ? 'World' : [h('i', {}, 'and'), 'bye'])
])
Enter fullscreen mode Exit fullscreen mode

Something like this using h(null)?

I implemented it in this way, however, creation works fine because I can use a documentFragment to append and return, however modification doesn't work well because the editing the documentFragment doesn't update the real DOM... So I imagine that here is necessary to track the parentNode (initial div) and use it during the modification... Or how you solved this?

Thanks a lot for your help @ryansolid

Thread Thread
 
aralroca profile image
Aral Roca

I finally resolved changing the hyperscript children to array, this way I can execute in order knowing the parent:

h('div', {}, [
  ['b', {}, 'Hello '],
  ['', {}, () => signal() ? 'World' : ['i', {}, 'and'], 'bye']
])
Enter fullscreen mode Exit fullscreen mode
Collapse
 
aralroca profile image
Aral Roca

I have managed to implement a draft version to use jsx and signals in web-components. I am very grateful and the article you have passed me helped me to understand the basics of signals better. Thank you very much for the article.

Collapse
 
artxe2 profile image
Yeom suyun

In many more situations than we might think, programmers have more information than a highly optimized compiler.
Wouldn't it be enough for a framework to open up the possibility of optimization by providing options for programmers to leverage this information when they need it?
For example, if a function is "pure", the result of the function could be cached, but the decision of whether to cache the result should be entirely up to the programmer.

Collapse
 
ryansolid profile image
Ryan Carniato • Edited

Yeah. In essence though you could say that is what React.memo, useMemo, and useCallback. The problem is it isn't really what people want to be thinking about. They ignore performance, then find out that some leaf component is inexplicably re-rendering 8 times on some small mutation. They have no idea how to trace it and they start slapping those on everywhere. Eventually they get it down to 2 and call it a day because they still are missing something. 6 months they come back and realize its running 4 times now because someone did something else in a parent component. And so on.

Models that remove the idea of "optimization mode" and just are more optimal to begin with have some benefits. React started looking into compilers, but you get this with Signals too just by their design.

Collapse
 
artxe2 profile image
Yeom suyun

I do not like React's memo function.
Memo is a function that reduces the cost of V-DOM, but I think frameworks like Solid and Svelte have proven that V-DOM itself is just pure overhead.
The implementation using signals is to say that there will be no performance problems in most cases even without additional optimization for excessive calls in the Limits to Locality of Thinking section.
But when I read the article again, it seems that the original content of the article is the same claim.

Collapse
 
titob profile image
Tito • Edited

Getters are an antipattern, things become impossible to track down, the language becomes less powerful (no ability to use destructuring, not in functions arguments and not in objects, etc), things become unpredictable (merged effects = values that come from props are re-evaluated).

The exact code that was causing me problems with the merged effects + props getters is:

width={
  html.clientWidth > html.clientHeight
    ? html.clientHeight - 450
    : html.clientWidth - 450
}
Enter fullscreen mode Exit fullscreen mode

I never expected that code to run more than once. I was shocked.

An isSignal on this context doesn't make sense, it has no value to know if something is a signal (for the value that being a signal provides), in any case you will unwrap the value, context github.com/solidjs/signals/issues/8

About props, you can freeze them with Object.freeze(props)

Anyway, I do love solid and the ideas you came with, signals, enchanted by dom-expressions output, just feeling pity it crossed the line with the props getter stuff, but that's just my preference. Thanks Ryan!, great source of inspiration

Collapse
 
ryansolid profile image
Ryan Carniato

Yeah it definitely pushes against the language a bit, although using objects of functions for props is also awkward because shape changing... can't really spread test the existence of something in a reactive way. Ie.. track something that isn't there yet. Not having isSignal has been a blessing though so I doubt we'd ever expose that as an external API. Which is difficult because if we are building a core library that people might use directly hiding it might be challenging if it was included. So while there are a couple places I wouldn't mind internally leveraging the optimization it just can't exist out there so will have to see what can be done.

Collapse
 
dsaga profile image
Dusan Petkovic

Thanks for the article, still trying to wrap my mind around how signals work..

How does a global state fit with locally of thinking? any disadvantage or using signals as a global state?

Collapse
 
ryansolid profile image
Ryan Carniato

You are right in that is the 3rd boundary point into our component. I didn't talk about it much in the article. It is really a 3rd category as you have a bit of both qualities as it is a lot like props where you have data and explicit mutators coming in, but you don't get to define the interface(the global store does) similar to how it is when you consume child components.

If the interface is well defined then in a sense this is no different than any composition pattern in components you use. Like custom Hooks/Primitives. Whether you own the state or created doesn't impact the mechanics of the component.

const [count, setCount] = createSignal(0);
// vs global lookup via context
const [count, setCount] = useContext(CounterContext);
Enter fullscreen mode Exit fullscreen mode

However, since you do not know the side effects of your actions the recommendation for global state design it is generally encouraged to be as specific as possible with mutation API. For instance this is better:

const [count, { increment, decrement }] = useContext(CounterContext);
Enter fullscreen mode Exit fullscreen mode

In general Signals(or Solid's Stores which are nested Proxy Signals) are great for global state. Most global state management libraries are some sort of event emitters and this one plays first party with the framework. But given above I do recommend not just exposing the setters directly if possible, and design an explicit mutation API.

Collapse
 
dsaga profile image
Dusan Petkovic

Thanks!! will try out solid, interested to see how it works

Collapse
 
btakita profile image
Brian Takita

How is development on Solid 2.0 with global signals going? Looking forward to using signals, memos, & resources for global state management!

Collapse
 
mfp22 profile image
Mike Pearson • Edited

React has passed many values down over the years. Probably hundreds of trillions. And I hope we learn from all of them.

Btw, unidirectionality meant a lot more than just values being passed down. Flux was the unidirectional thing when they first described it, and React was described as a "declarative rendering framework." Declarative rendering with JSX is unidirectional, but it's kind of boring. What's interesting is the full cycle from event to DOM render. That's what MVC vs unidirectionality was about. Without something like Flux, React is an MVC framework. People forget the origins, because most apps require one data source and a few trivial state changes, so it doesn't matter. But React and Flux were designed in an environment that was much more complex than most applications: a chat app. The way most React apps are architected today is not much better than what came before React. It's event handlers controlling various states. JSX and components are the real improvement that stuck.

Collapse
 
mfp22 profile image
Mike Pearson

@ryansolid if you can watch my UtahJS talk and come away still not agreeing that React is an MVC framework, I'll give you the gift card they gave me for speaking. youtu.be/yCrrkBV4-K8?si=uxMbSQv-2q...