DEV Community

Miha Mulec
Miha Mulec

Posted on AI-assisted

Fun-grained reactivity in Angular: Part 5 - Nested Effects

Hey everyone :) Been a while... I've been busy updating the libs, so those dozens of you who've enjoyed mmstack as it's grown will already know some of this. Still, getting it out on "paper" is usually good. Alrighty, let's get into it...effects.

Never, ever, ever ever, ever EVER ever use effects ... ever

Look, I know, I've been there...an effect synchronizing a few signals couldn't hurt, right? It's just one. And yeah, I've seen systems full of 'em & they worked (mostly). The trouble comes when they start depending on one another. Soon we're juggling dozens, then hundreds of side effects, spread across files whose authors all thought they were adding just one. In the large systems I usually end up assigned to, it'd all collapse like a house of cards if I wasn't strict about it.

Signals aren't particularly special here...writes tucked into RxJS tap callbacks & side-effectful functions can make data flow and ownership just as difficult to follow. With effects we also have scheduling to account for. The source changes now; the effect that copies it somewhere else runs later.

That gap may never make it onto the screen, but we can still read the state while the values disagree. Add another effect & a few more signals:

const name = signal("John");
const lastName = signal("Doe");
const fullName = signal(`${name()} ${lastName()}`);
const age = signal(30);
const label = signal(`${fullName()} is ${age()}`);

effect(() => fullName.set(`${name()} ${lastName()}`));
effect(() => label.set(`${fullName()} is ${age()}`));

name.set("Jane");
console.log(name(), fullName(), label());
// "Jane", "John Doe", "John Doe is 30"
// The effects haven't run yet.
Enter fullscreen mode Exit fullscreen mode

Now imagine a submit method reading that label immediately after updating the name. Changing the order of a few .set() calls won't make the effects synchronous. I know this is a trivial example; in my experience it's usually much worse, with the source, the copied values & whatever reads them dispersed among many files and intertwined with the rest of the business logic.

For these two values we can express the dependencies directly:

const fullName = computed(() => `${name()} ${lastName()}`);
const label = computed(() => `${fullName()} is ${age()}`);
Enter fullscreen mode Exit fullscreen mode

Read label() after changing the name & it derives from the current inputs. That's the rule I'd like to keep all the way down: derivation over synchronization. Mostly for my own sanity, & because I'd like the system to survive the PM's latest idea next Tuesday.

So computeds all the way down?

Well...yeah, pretty much. Or derivations rather. For normal stuff a chain of computeds may be all we need. mmstack's derived gives us a writable view back into its source, while linkedSignal is useful when we want to hold local state & decide how it follows a changing source.

Say we have the usual table -> edit button -> dialog -> PATCH setup. The dialog starts from an item in the table's response, but the table can page away or refresh while we're editing. We'd like to keep the form open, and if the item comes back with new data, reconcile it with what we've already entered:

const todos = httpResource<Todo[]>(() => "/api/todos");

const todoState = linkedSignal<Todo | undefined, TodoState>({
  source: () =>
    todos.hasValue()
      ? todos.value().find((t) => t.id === id) // id is fixed for this dialog
      : undefined,
  computation: (next, prev) => {
    if (!next) return prev?.value ?? createTodoState();
    return prev ? prev.value.reconcile(next) : createTodoState(next);
  },
});
Enter fullscreen mode Exit fullscreen mode

TodoState, createTodoState & reconcile are our application helpers here. The reconciliation returns the next form state. When the item disappears from the response, we hold the previous state. For a new dialog without an item, this example starts empty. An edit dialog should still distinguish loading, a failed request & an item that actually no longer exists before allowing submission.

There are plenty of forms of derivation, some in the framework (computed, linkedSignal, resources), others in libraries or our own code (indexArray, keyArray & so on). Finding or making the right one is usually the tricky part :) If the value belongs to the graph, that's where I'd spend the effort.

Alrighty, now that we're never using effects - let's use some effects :D

No matter how many primitives we mint, or how many signals within signals we put in the graph...eventually a value has to leave it. The obvious places are the DOM & HTTP calls, where Angular already gives us the machinery to handle that boundary. Thanks, core team :D

Then there are libraries. Sooo many libraries that aren't signal-based & never will be. BabylonJS was the one I was wrapping my brain around when I made this primitive, though I've since used it in quite a few other integrations, including Monaco.

We can go oldschool with setter @Inputs, or use effects to pass our values across, which is the bit I'd like to express through nesting: create the instance in a parent, then let its update effects live for that run.

This helper has actually been around for about a year, so I'm a little late getting to the explanation...anyway, let's get into it.

The full version is available as nestedEffect in @mmstack/primitives/core, also re-exported by @mmstack/primitives. The example below leaves out some cleanup guards & options; we'll get to those where they matter.

Connecting to other libraries

A chart is the easiest to show, so say we're integrating a charting library. It has its own ways of accepting data, choosing a theme & so on, which is fine, we can call those from an effect:

const chart = createChartInstance(el);

effect(() => {
  chart.setData(data());
  chart.setTheme(theme());
  chart.setLocale(locale());
});
Enter fullscreen mode Exit fullscreen mode

Nothing particularly wrong with this for a small chart. It does mean that a new data point also calls setTheme & setLocale though, since the effect reads all three signals. If the data is streaming in continuously we're asking the library to apply the same theme over & over, & whether that's cheap depends on what it does with that call. I'd rather not need to find out just to update the data :)

Of course, we could split it into three effects & leave it there. But then consider creating the chart only while it's visible, or creating an effect for each item in an array. We'd like the update effects to exist for as long as the thing they're updating, & stop when it goes away. Putting an effect inside another one looks like it should express that, but Angular doesn't automatically destroy the child when the parent re-runs. We can create it with an injector & untracked, then register the cleanup ourselves...at which point it seems useful to give that pattern a helper.

This is another place where Solid is worth a look. A computation created inside another belongs to it & is disposed when the parent re-runs or is destroyed, which you can read more about in its cleanup docs. Angular gives us component & injector lifetimes through DestroyRef, but a chart might be created & replaced several times within one component's lifetime. It's those individual runs we'd like to clean up too.

nestedEffect

We'll keep a stack of frames while effect bodies run. The current frame collects any nested effects created during that run, & the parent's cleanup destroys them. Each frame also carries the injector, since we need one to create the children:

type Frame = { injector: Injector; children: Set<EffectRef> };
const stack: Frame[] = []; // module or root scoped (there should be only 1)

export function nestedEffect(
  fn: (onCleanup: EffectCleanupRegisterFn) => void,
): EffectRef {
  const parent = stack.at(-1) ?? null;
  const injector = parent?.injector ?? inject(Injector);

  const ref = untracked(() =>
    // Angular schedules the effect; the frame handles child cleanup.
    effect(
      (onCleanup) => {
        const frame: Frame = { injector, children: new Set() };
        const cleanups: (() => void)[] = [];
        stack.push(frame);
        try {
          fn((cleanup) => cleanups.push(cleanup));
        } finally {
          stack.pop();
        }
        // parent re-ran or died → children of the previous run go with it
        onCleanup(() => {
          // Current production order: user cleanups, then children.
          for (const cleanup of cleanups) cleanup();
          for (const child of frame.children) child.destroy();
        });
      },
      { injector, manualCleanup: !!parent },
    ),
  );

  parent?.children.add(ref);
  return ref;
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the child gets its injector from the parent frame, so it doesn't need to find a new injection context. We set manualCleanup when there's a parent, because we've taken responsibility for destroying that child. A top-level call still uses the injector's DestroyRef, much as a normal effect would.

There is an ordering detail here I'd like to call out before we use it. The parent's cleanup callbacks run first, in registration order, then its children are destroyed. So if a child's cleanup needs, say, the connection the parent created, closing that connection first is going to be a problem. We can explicitly destroy the child before closing it, which is what the examples below do. So nesting takes care of forgetting the child, the order is still on us.

The untracked around construction is also worth keeping. It allows the effect to be created from an active reactive context & prevents reads made during construction from subscribing the parent. Once Angular runs the child's body, that body tracks its own dependencies as usual. Otherwise a read while setting up the child could quietly make our supposedly rarely-changing parent run again.

What happens on a re-run

Say the parent reads coldGuard & creates a child that reads hotSignal:

  1. Changing hotSignal only makes the child re-run.
  2. Changing coldGuard makes the parent re-run. Its previous cleanup runs first, including the parent's callbacks & destruction of the child.
  3. The parent then runs with a fresh frame. Any nested effects it creates belong to that run.

Notice that on the next run we don't have to create the same children. If an if branch is no longer taken, its old child has already been cleaned up & we simply don't create another. In other words, the if is the lifetime :)

Hot & cold

For example, a connection only needs to be opened when it's enabled & reopened if the URL changes. Sending a message shouldn't reconnect it, fairly basic requirement ;) We'll read those rarely changing, or "cold", values in the parent & the frequent "hot" updates in a child:

nestedEffect((onCleanup) => {
  // cold: tracks `disabled`
  if (disabled()) return;

  const conn = connect(url()); // cold too, reconnect on url change

  const send = nestedEffect(() => {
    // hot: tracks only `outgoing`, the parent never re-runs for it
    conn.send(outgoing());
  });

  onCleanup(() => {
    send.destroy();
    conn.close();
  });
});
Enter fullscreen mode Exit fullscreen mode

While disabled is true, we don't even get as far as creating the child, so changes to outgoing have nothing here to notify. Enable the connection & the child starts sending updates through it. Disable it again, or change the URL, & the cleanup destroys send before closing the old connection. This is why I kept the reference to the child even though the parent will eventually destroy it anyway.

Angular still schedules these effects. Component effects run during Angular synchronization, while root effects run as microtasks; the injector & options determine which we get. Children are created when the parent's body runs, so nesting doesn't make updates synchronous.

Back to the chart

Anyway, back to the chart. Its container determines when we need a new instance; theme, locale & data only determine what we do with the existing one:

nestedEffect((onCleanup) => {
  // COLD: instance lifetime. Tracks `container` only, so the chart is
  // created once & recreated only if the element itself changes.
  const chart = createChartInstance(container());

  const themeRef = nestedEffect(() => chart.setTheme(theme())); // warm: rare
  const localeRef = nestedEffect(() => chart.setLocale(locale())); // warm: rare
  const dataRef = nestedEffect(() => chart.setData(data())); // hot: 60/s while streaming

  onCleanup(() => {
    themeRef.destroy();
    localeRef.destroy();
    dataRef.destroy();
    chart.destroy();
  });
});
Enter fullscreen mode Exit fullscreen mode

Now the streaming data only calls setData. Changing the theme calls setTheme, & replacing the container disposes the update effects & chart before setting them up against the new element. The cleanup is a few lines longer than simply calling chart.destroy(), but it also makes the order fairly obvious when we come back to this code later.

The same approach works for maps, editors or video players: create the instance in the parent, then use children for the values we need to pass to it.

An editor, its model & its language

Monaco gives us another level to work with. In our editor directive the outer effect waits for Monaco to load & creates the editor. Children pass in the value and options, and one child handles the active text model. Inside that child there's another effect for the model's language.

Here's that last part on its own. monaco is the loader's signal, model is a signal containing a caller-supplied text model or null (in which case the editor keeps the one it created from value), language is another input & el is the host element:

nestedEffect((cleanup) => {
  const m = monaco();
  if (!m) return; // satisfy TS

  const editor = m.editor.create(el, { model: untracked(model) });

  const modelRef = nestedEffect(() => {
    const mod = model();
    const editorModel = editor.getModel();
    if (mod && mod !== editorModel) editor.setModel(mod);

    const target = mod ?? editorModel;
    if (!target) return;

    nestedEffect(() => {
      const next = language();
      if (target.getLanguageId() !== next) {
        m.editor.setModelLanguage(target, next);
      }
    });
  });

  cleanup(() => {
    modelRef.destroy();
    editor.dispose();
  });
});
Enter fullscreen mode Exit fullscreen mode

Change the language & only the innermost effect runs. Switch files, giving us another model, & the previous language effect goes away before we create one for the new target. The editor itself stays put. Destroy the outer run & we stop the model effect, including its language child, before disposing the editor. The directive itself just disposes the editor & lets the frame destroy the children afterwards, which works since a destroyed effect never runs again, I've just made the order explicit here. The caller still owns those text models; disposing a view shouldn't dispose a model another editor may also be using.

The full directive also listens to Monaco's content-change events to bring user edits into the form state. In the other direction, its value effect checks value !== editor.getValue() before calling setValue, and the event handler can ignore a value we already hold. Values go in through effects, user edits come back out through events, & our labels, validation & whatever else keep deriving from the form state as before.

Effects owned by array entries

Arrays are where I need to qualify this a bit. indexArray/keyArray, building on the mapping from Part 4, keep their entries across reads. If an effect reads a lazy mapper, the effects created for new entries during that read would belong to its current run. Let that reader re-run & we'd destroy those children...while the mapper still has the entries & no reason to create them again. We've successfully kept the rows stable & lost their updates, which isn't quite the optimization we wanted.

Here the mapped entry needs to own the effect. The library version has options for choosing that ownership explicitly:

// Run setup in an injection context.
const injector = inject(Injector);
const destroyRef = inject(DestroyRef);
const owned = new Set<EffectRef>();

const rows = indexArray(
  items,
  (item, index) => {
    const ref = nestedEffect(() => thirdPartyGrid.updateRow(index, item()), {
      injector,
      manualCleanup: true,
      bindToFrame: () => null, // owned by the mapped entry, not its reader
    });
    owned.add(ref);
    return ref;
  },
  {
    onDestroy: (ref) => {
      ref.destroy();
      owned.delete(ref);
    },
  },
);

const reader = nestedEffect(() => void rows());
destroyRef.onDestroy(() => {
  reader.destroy();
  for (const ref of owned) ref.destroy();
  owned.clear();
});
Enter fullscreen mode Exit fullscreen mode

Each row has an effect reading its own item signal, & reader makes sure the mapper is actually evaluated so entries get created & removed. There are two cleanups because there are two ways to be done with a row: the mapper can remove it, or the entire owner can be destroyed while that row is still present. onDestroy handles the first; the final DestroyRef callback handles whatever is left. A removed row's reference is also deleted from owned, so we aren't keeping all the rows we've ever rendered around.

indexArray tracks positions. Shrinking removes trailing slots, while deleting from the middle changes the values at the surviving positions. Use keyArray if a widget needs to follow a particular item through reorders. For example, a map marker could belong to an entry keyed by location ID. Its update effects & the marker itself should be destroyed before the map.

Drag & drop

One place we use this is @mmstack/dnd's pointer engine. pointerDrag from the sensors family exposes the gesture as a signal. The sortable engine reads it to start, update & finish the drag:

nestedEffect(() => {
  const g = drag.unthrottled();
  if (g.active) {
    if (!dragging) {
      controller.beginGesture(key, g.start); // measure once, cold
      dragging = true;
    }
    controller.move(g.current); // hot, every frame
  } else if (dragging) {
    g.cancelled ? controller.cancel() : controller.end();
    dragging = false;
  }
});
Enter fullscreen mode Exit fullscreen mode

The current implementation puts this update logic in driveGesture. There is still ordinary state involved, such as remembering whether we've started dragging, & explicit cleanup for the controller. Each sortable item's nestedEffect registers its key & element, then unregisters them on cleanup; the container uses DestroyRef to stop auto-scroll & dispose the controller. I wouldn't try to make all of that disappear into the helper, the gesture still has its own start & end to manage.

Pausing an effect

There's a smaller use for this which will come up again in the next article. Say a view stays mounted while hidden, & we'd like some of its effects to stop doing work until it's visible again. We can check a pause signal before reading anything else:

nestedEffect(() => {
  if (paused()) return; // only tracks paused until we resume
  actualWork();
});
Enter fullscreen mode Exit fullscreen mode

Once the effect runs with paused() set to true, it only tracks that predicate. Changes to signals inside actualWork() won't cause it to run. When we resume, it reads & tracks those dependencies again.

A few limitations

The parent still destroys & recreates its children on every run. If we read streaming data in the parent & create the chart in a child, we'll get a brand new chart on each update. Technically everything is being cleaned up correctly, but I'd prefer to keep the chart xD Put the expensive setup around the rarely changing inputs, & read the frequent updates in children.

Also, the stack only exists while the body is running synchronously. A setTimeout callback might be written inside the parent, but by the time it runs that frame is gone. An effect created there needs its own injector unless the callback establishes an injection context, & it won't automatically belong to the earlier parent run.

You can still destroy a child early through its EffectRef. The production version handles repeated destruction, accepts Angular's effect options & adds bindToFrame for choosing an owner, as in the mapper example. It also catches errors in each cleanup callback so the remaining callbacks & children are still cleaned up. Those guards are left out of the simplified version above.

For a single value passed to a library I'd still use a plain effect. Once there's an instance with several independent updates & a lifetime to manage, nesting becomes useful.

Well, that's about it for nested effects :) Next time we'll look at async state, where keeping the individual updates small isn't quite enough to keep the UI consistent. 🚀

Top comments (0)