preactjs / signals

Manage state with style in every framework
https://preactjs.com/blog/introducing-signals/
MIT License
3.64k stars 89 forks source link

Feature request: atomic transactions #460

Closed PsychoLlama closed 5 months ago

PsychoLlama commented 7 months ago

[!NOTE] I built a proof of concept over here: https://github.com/preactjs/signals/pull/461

Hi! First off, let me say I love the library. Beautiful work :clap:

One thing I'd love to see is an option similar to batch(...) for atomic transactions. A common problem with state management is that if a change throws an error halfway through, the state can get corrupted and render the app inoperable. Here's a toy example:

batch(() => {
  loading.value = true

  // 💥 TypeError: entities.value[index] is undefined
  // Now the app is stuck in a loading state.
  const entityId = entities.value[index].id

  // ... unreachable code ...
})

You could imagine this with URL parse errors, out-of-order async actions, timers that fire after the page navigates away... you get the idea. This is even more damaging if you develop in an environment where the user can't refresh.

For most of my app's state transitions, I want these changes to be all-or-nothing. If the update throws an error mid-way through, the entire update is invalid and should be discarded.

transaction(() => {
  loading.value = true

  // 💥 TypeError: Transaction is aborted.
  const entityId = entities.value[index].id

 // ... unreachable code ...
})

// The in-progress update was aborted, the loading state was rolled back.
loading.value // `false`

It's hard to implement this behavior outside the library. I believe it belongs in core.

XantreDev commented 7 months ago

It's interesting, but I have doubts about this feature. I see no reactivity system which implements it: Vue.js, solid.js, angular. For now I see not a lot of use cases

PsychoLlama commented 7 months ago

@XantreGodlike It's not something libraries generally have to implement explicitly, no. It's an implicit property you get from immutable state management systems, particularly Redux. Since every change happens in a "batch" (action) and only committed when the reducer returns, any error in between gets reported without committing intermediate states and leaving the application inconsistent. These systems are widely deployed. With disparate stores like signals, you lose this property.

This isn't a behavior I have to think about when I'm designing applications, but it's a safety net that improves the customer experience. It means more actions can be retried without resorting to refreshes.

For my use case, I'm not using @preact/signals directly. I've got a small action harness around it that keeps the benefits of signals (easier code splitting, type safety, value outside a component framework) while preserving the benefits of a Flux architecture (audit log, change observability, free analytics). The only feature I can't implement correctly outside core is atomic rollback.

I can wrap signals with my own getters/setters that restore initial state if an action fails, but after a rollback I can't prevent effects from detecting a version change.

const count = signal(0);

effect(() => {
  console.log('Change observed:', count.value);
});

// Effect fires.

batch(() => {
  count.value = 1;

  // Imagine an error happened. Roll back to original value.
  count.value = 0;
});

// FAIL: Effect fires again, even though values are identical.

This is important to me. While inconsistent state might be an annoyance in a UI where a user can refresh, it can be disastrous on a server. You might wonder what kind of madman would use signals on a server. Well, I work in the video conferencing field where servers are very stateful. Signals are attractive. A bad request that corrupts state might disconnect everyone else from their call, even those in different rooms.

I can't say I'm using signals for video conferencing, but it's a concrete example where atomic rollbacks are important. The ergonomics of signals are appealing and open the door to new use cases. These use cases are expressible by overlaying another API, but rollback cannot be implemented outside core without leaking the abstraction.

rschristian commented 6 months ago

While I understand the appeal, this is entirely solvable with try...catch. This is something that should be addressed in user code rather than the lib, IMO.

PsychoLlama commented 5 months ago

This is something that should be addressed in user code rather than the lib

I agree, that would be ideal. The reason for this issue is because it can't be properly implemented in user space. Unless the signal's internal (private) version is rolled back, a try...catch will fail this test. A rollback in user code can't prevent the side effects.

I won't push the issue further. I can read the room, it sounds like this isn't a feature the core team is interested in. But I appreciate you both humoring the idea :bow: