Connect Middleware

The work of this implementation is being done in this PR: https://github.com/frontity/frontity/pull/213

Thereā€™s a failing test in wp-source because of weā€™ve changed how we invalidate observers.

For example, if an observer is subscribed to two props:

const state = {
  prop1: "1",
  prop2: "1",

observe(() => {
  const { prop1, prop2 } = state.obj;
  console.log(`prop1: ${prop1} & prop2: ${prop2)`);
});

And you change the state.obj all at once:

state.obj = { prop1: "2", prop2: "2" };

Youā€™ll get only one call to the observer:

// console.log => prop1: 2 & prop2: 2

Because there was only one mutation: state.obj.

But with this implementation we have two mutations: state.obj.prop1 and state.obj.prop2 and we get two console.logs:

console.log => prop1: 2 & prop2: 1 // Unstable?
console.log => prop1: 2 & prop2: 2

This is not something that couldnā€™t exist in the past, because if you do this:

state.obj.prop1 = "2";
state.obj.prop2 = "2";

youā€™ll get the two console logs as well.

console.log => prop1: 2 & prop2: 1 // Unstable?
console.log => prop1: 2 & prop2: 2

Going forward, we need to decide a few things:

  • Are we going to have a common internal implementation for middleware and computations?
  • Are we going to have a common external API for middleware and computations?
  • Are middleware/computations going to be sync/async by default?

This is a computation:

observe(() => {
  console.log(state.user.name);
});

Although they can have other forms, like connect or Mobx reactions:

// React connect computation
const UserName = ({ state }) => (
  <div>{state.user.name}</div>
);

export default connect(UserName);
// Mobx reaction computation
reaction(
  () => state.user.name,
  name => console.log(state.user.name);
});

Middleware needs to run before the event happens because it needs to be able to modify it:

  • Before the mutation is done.
    • It can abort the mutation.
    • It can modify the mutation.
  • Before the action is executed.
    • It can abort the action execution.
    • It can modify the arguments.

Once the action has been executed or the mutation has been done, middleware and computations are basically the same thing. For example:

// This...
observe(() => {
  console.log(state.user.name);
});
// ...is equivalent to this:
addMutationMiddleware(({ state, next }) => {
  await next() // wait until the state mutation has finished.
  console.log(state.user.name);
});
// or this...
addMiddleware({
  test: ({ path }) => path === "state.user.name",
  afterMutation: ({ state }) => {
    console.log(state.user.name);
  }
});

Sync or Async?

Async problems:

  • Actions wonā€™t work if they use a value right after changing it:
// This middleware aborts a mutation.
addMiddleware({
  test: ({ state }) => state.user.name,
  onMutation: ({ abort }) => {
    abort();
  }
});

const state = { user: { name: "Jon" } };

const myAction = ({ state }) => {
  state.user.name = "Daenerys"; // Doesn't trigger the middleware just yet!
  console.log(state.user.name) // -> "Daenerys" -- WRONG VALUE!!
}

Async benefits:

  • We can improve the performance using facebookā€™s scheduler.
  • We can avoid ā€œunstableā€ states by batching mutations together:
addMiddleware({
  test: ({ state }) => {
    state.user.name;
    state.user.surname;
  },
  afterMutation: ({ state }) => {
    console.log(state.user.name + " " + state.user.surname);
  }
});

const state = { user: { name: "Jon", surname: "Snow" } };

const myAction = ({ state }) => {
  state.user.name = "Daenerys";
  state.user.surname = "Taergaryen";
  // If synchronous, this would trigger two console.logs:
  // 1. "Daenerys Snow"  -- WRONG!! (unstable)
  // 2. "Daenerys Taergaryen"  -- Right
}

State dependencies

Apart from middleware for action executions and state mutations, we need another middleware for ā€œstate dependenciesā€.

That will be used by our devtools to show ā€œwho is listening to whatā€. For example:

- UserName component is listening to state.user.name.

We do that with the context we pass to that component. For example, this is the context of the state object we pass to UserName:

const context = {
  type: "component",
  name: "UserName",
  id: 123,
};

We could add that ability to our middleware:

addMiddleware({
  name: "user name log",
  test: ({ state }) => state.user.name,
  reaction: ({ state }) => console.log(state.user.name),
});

And the dependency reported would be:

- "user name log" middleware is listening to state.user.name.

I guess thereā€™s not much point on adding that to computations/middleware if they donā€™t have a name:

observe(({ state }) => {
  console.log(state.user.name);
});

- ??? middleware is listening to state.user.name.

By the way, when I write I use a lot of possible ā€œmiddleware APIsā€ just to get a feeling of how they look likeā€¦ Please bear with me :roll_eyes:

About the async problem with actions that use the value right after chaning it, like this:

// This middleware aborts a mutation.
addMiddleware({
  test: ({ state }) => state.user.name,
  onMutation: ({ abort }) => {
    abort();
  }
});

const state = { user: { name: "Jon" } };

const myAction = ({ state }) => {
  state.user.name = "Daenerys"; // Doesn't trigger the middleware just yet!
  console.log(state.user.name) // -> "Daenerys" -- WRONG VALUE!!
}

We could solve it with an opt-in await:

const myAction = async ({ state, mutationsFinished }) => {
  state.user.name = "Daenerys"; // Doesn't trigger the middleware just yet!
  await mutationsFinished(); // Now we trigger them.
  console.log(state.user.name) // -> "Jon" <-- Right value
}

Or we could do the opposite, make them sync by default and add a function to batch them and make them async. Something similar to Mobx transactions:

// This just works:
const myAction = async ({ state, transaction }) => {
  state.user.name = "Daenerys";
  console.log(state.user.name) // -> "Jon" <-- Right value
}

// But you can batch changes and improve performance on big deep 
// overwrites using transaction:
const myAction = async ({ state, transaction }) => {
  await transaction(() => {
    state.user.name = "Daenerys";
    state.user.surname = "Taergaryen";
  });
  console.log(state.user.name);
}

Neither of those are ideal, but I donā€™t see another way out so far.

I think there is another problem here actually:

If we allow aborting actions, itā€™s gonna possibly be problematic: what if a particular action issues several mutations and then the user relies on the fact that the first mutation has happened? EDIT: I realized that you address that later on, Luis, so just keep reading :slight_smile:

I think that making middleware async can result in explosion in complexity. MobX run sync and Michel gives some good reasons for keeping it this way here: https://hackernoon.com/the-fundamental-principles-behind-mobx-7a725f71f3e8 I think you alluded to this before, but after our fiasco with the deepOverwrite Iā€™m willing to be a bit more humble about my own ideas. I asked him on twitter if he has any further thoughts about sync/async.

I wanna point out that I have some reservations about middleware being able to abort actions and call other actions (which will then trigger other middleware). I feel like this is both likely to result in a spiderweb of action -> middleware -> another action called from middleware -> middleware for that action -> and so onā€¦

I think that they are not exactly the same. The observe cannot be async (e.g. react components are pure synchronous functions). So, as far as I understand the concepts and the implementations have to differā€¦ unless Iā€™m missing something :slight_smile:

Iā€™m not sure if I see the value in having the middleware to be async at this point. My gut feeling is that: a) the sync implementation is gonna be simpler. b) I feel like transactions are possibly an easier concept to grasp (pretty much all DB systems have transactions). And Iā€™m also guessing that there is probably no third way here - MobX has runInAction for this reason.

In a more general sense, I feel like we should take a little step back and think of the WHY of frontity-connectā€¦ and the middleware? Because I have a feeling like Iā€™m trying to solve some very specific technical problems, but maybe weā€™re missing the forest for the trees (I really like that saying :smiley:) What I mean is that I understand that we want to have a super dev-friendly state manager with devtools for best possible developer experience, but what are the actual problems that we try to solve? Is there a way we can get away with maybe doing much less work and getting 90% of the result (at least for now)? I mean this all in very real sense and not just philosphically.

Just as a sort of PS: I also have some vague idea about how we could maybe make our middleware more like an effect system in the spirit of react hooks. Basically, the observers are like wrappers for computations (functions). And react components are just functions which can have effects. So, maybe there is a way to reuse the familiar hooks API to control the execution of those functions. But also maybe this is nonsense. Iā€™m talking about something like (this is completely improvised) :

// state middleware
const userMiddleware = ({state}) => {

  // it runs some effect only on state.user change
  useEffect(() => {

  }, [state.user]);

  // if we return null the state update is aborted
  return null;

  // we can just return unmodified state 
  return state

  // or we can modify the state update
  state.user = 'Michal';
  return state.user;
};

userMiddleware()

I read that. Good one:

I found the original article on archive.org:

I agree with Michel that sync mutations are indispensable, in order to avoid this problem:

const user = observable({
  firstName: ā€œMichelā€,
  lastName: ā€œWeststrateā€, 
  fullName: computed(function() {
    return this.firstName + " " + this.lastName
  })
})
user.lastName = ā€œVaillantā€
sendLetterToUser(user) // <- needs an updated "user"

And I agree with Michel that sync reactions are preferable. But I donā€™t think sync reactions are indispensable.

Actually, it looks like in the version of nx-js/observer-utils of that article observers were always async. In the current version, they are sync with an opt-in async (with the schedule option).

I understand your concern and itā€™s probably a good one, but I wouldnā€™t like to add constraints in the beginning. Imagine that one package needs to trigger an action when the action of other package is aborted. If we limit the action execution in middleware, we will limit that type of interactions.

Oh, yeah, sorry. I was talking about how we trigger middleware and reactions, not about the type of callbacks they support.

At this point, I think we need a glossary of terms to be able to communicate better.

I agree with you that simpler is better but thereā€™s no way to mix transparent sync transactions with async/await actions: https://mobx.js.org/best/actions.html

So we need to find a compromise for that.

Nice idea. Iā€™ll add it with the rest so we can explore it.

I agree. Letā€™s take a step back, do a summary of the current state and the overall goal.

Glossary

  • "Raw state":
    The original state object developers declare in their app.
  • "State":
    The state object after it has been proxified by our library.
  • "Actions":
    The functions developers declare in their app.
  • "Action execution":
    One execution of an action.
  • "Mutation":
    A change in some part of the state.
  • "Computation":
    Callbacks subscribed to a specific action or part of the state. They are executed after that action is executed or that mutation happens. ā€“ Weā€™ve used ā€œreactionsā€ or ā€œobserversā€ to refer to this before this post.
  • "React Computation":
    React components subscribed to a specific part of the state. They are re-rendered after the mutation happens. ā€“ This is our current connect function.
  • "Middleware":
    Callbacks subscribed to a specific action or part of the state.
    They are executed before that action is executed or that mutation happens.
  • "Transaction":
    A function or implementation that joins all the mutations that happened during its duration together in order to avoid multiple unnecessary calls to computations.

@mmczaplinski feel free to edit this post to add/modify any concept.

Goals

I think these are still valid:

  • Minimum rerender guarantee
  • Avoid breaking object/array references on mutations
  • Middleware support for action executions
  • Middleware support for mutations

Iā€™d add these two more, to be more concise:

  • Computations triggered by successful mutations
  • Computations triggered by successful action executions

Avoid breaking object/array references on mutations

Just for the record, Michal realized we cannot fix this without breaking other use cases of references.

So in order to solve this:

const state = {
  user: { 
    name: "Jon",
    surname: "Snow"
  }
};

const asyncAction = async ({ state }) => {
  // Store a reference to state.user.
  const user = state.user; 

  // Yield the action, maybe do some fetching...
  await new Promise(resolve => setTimeout(resolve, 5000));

  // Consider that while this function is waiting, the
  // changeNameObject actions is called, changing the 
  // reference of state.user.

  // Now, user.surname is not valid anymore. Still holds
  // the value of "Snow".
  console.log(user.surname); // <- "Snow"
  console.log(user.surname === state.user.surname); // <- false
};

We are breaking this:

const state = [{ id: 0 }, { id: 1 }];

// Use object references to swap an item array
const temp = obs[0];
obs[0] = obs[1];
obs[1] = temp;

So we cannot solve this bug.

The implementations proposed (stable-proxy-references and deep-overwrite) do not apply anymore.

Alternative solution

We can find a way to warn the developer when we detect that a reference is used without accessing the root state after an await:

const user = state.user;
await something();
console.log(user.name); // <= warn about unsafe use.

Minimum rerender guarantee

We have one solid implementation in mind that still applies: the deep-overwrite. Although it has its drawbacks so we can discuss alternatives.

Middleware support for action executions

They need, at least, the ability to:

  • Subscribe to specific actions.
  • Abort action executions.
  • Modify the action arguments.

Itā€™d be great if they can:

  • Be removed by other packages.
  • Be reprioritized by other packages.

Iā€™ll write later about the different ideas we have so far for the API.

Middleware support for mutations

They need, at least, the ability to:

  • Subscribe to specific mutations.
  • Abort mutations.
  • Modify the mutation.

Itā€™d be great if they can:

  • Be removed by other packages.
  • Be reprioritized by other packages.

Iā€™ll write later about the different ideas we have so far for the API.

Computations triggered by successful mutations

They need at least the ability to:

  • Subscribe to specific mutations.

Itā€™d be great if they can:

  • Avoid ā€œunstableā€ runs (somehow using transactions in our actions).

Iā€™ll write later about the different ideas we have so far for the API.

Computations triggered by successful action executions

They need at least the ability to:

  • Subscribe to specific actions.

Iā€™ll write later about the different ideas we have so far for the API.

Implementation ideas for ā€œminimum rerender guaranteeā€

deep-diff

The deep-overwrite implementation still applies although it wouldnā€™t overwrite anymore. We can call it deep-diff.

smart-deep-diff

If we were to avoid diffing all the nodes to improve performance, the only thing that I can think of is to diff only the nodes with subscriptions.

So, for example, if there are only nodes with middleware/computations subscriptions are the orange nodes, we only need to deep-diff through the orange lines.

The main disadvantage I see of smart-deep-diff vs deep-diff is that we loose the ability to do subscriptions with string/regexp of paths.

For example, in this scenario:

const state = { user: { name: "Jon", surname: "Snow" } };

addMiddleware({
  test: ({ path }) => path === "state.user.name",
  ...
});

If we trigger this mutation:

state.user = { name: "Jon", surname: "Targaryen" }; 

How do we know which node ("user.name" or "user.surname") has subscriptions and which not?

Some thoughts/questions:

1. Mutations that overwrite references

For example, should this two mutations be treated differently even tho they end up with the same state?

const state = { user: { name: "Jon" } };

// Mutation 1:
state.user.name = "Ned"; 
// Mutation 2:
state.user = { name: "Ned" }; 

Should they trigger this computation? Both? None? Only one?

observer(() => { console.log(state.user); });

I guess the question reduces to: should mutating a node from an object reference to different object reference trigger a mutation for that node?.

This is true for the current implementation but it was false for deep-overwrite.

2. Same reference in multiple parts of the state.

Do we allow to point to the same reference in multiple parts of the state?

const state = { arr: [] };
const obj = { value: 1 };
state.arr[0] = obj;
state.arr[1] = obj;

This would mean a couple of things:

  • We cannot have one path per proxy: whatā€™s the path of obj? state.arr.0 or state.arr.1?
  • It will behave differently in the server than in the client, due to the serialization of the state to be sent to the client (because at that point, the references are broken).
// server:
state.arr[0].value = 2;
state.arr[1].value === 2 // <- true

// Serialize the state to send it to the client...
const seralizedState = JSON.stringify(state); 

// Rehydrate state in the client...
const state = JSON.parse(seralizedState);

// client:
state.arr[0].value = 2;
state.arr[1].value === 2 // <- false, still 1 because they have different references

If we donā€™t want to allow this, should we deep-clone when we detect the same reference being used twice or should we throw an error?

smart-deep-diff

If we were to avoid diffing all the nodes to improve performance, the only thing that I can think of is to diff only the nodes with subscriptions.

So, for example, if there are only nodes with middleware/computations subscriptions are the orange nodes, we only need to deep-diff through the orange lines.
The main disadvantage I see of smart-deep-diff vs deep-diff is that we loose the ability to do subscriptions with string/regexp of paths.

So far the only solution to this based on the discussion with Luis is to ā€œproxifyā€ the middleware. So, we are able to ā€œsubscribeā€ the parts of the state that are used in a particular middleware and only trigger the middleware if those are changed. In essence, treating the middleware as just another kind of computation. What are the drawbacks / caveats with this approach?

1. Mutations that overwrite references

Should they trigger this computation? Both? None? Only one?

observer(() => { console.log(state.user); });

I guess the question reduces to: should mutating a node from an object reference to different object reference trigger a mutation for that node? .

This is true for the current implementation but it was false for deep-overwrite .

I would quite strongly argue that the answer should be both. Even though some of the nodes of the ā€œnewā€ object are the same as in the ā€œoldā€ one, the INTENTION of the developer in this case was: "Overwrite state.user". I think if we try to ā€œhelpā€ them by not showing those mutations, we will end up confusing them.

I recall that the redux devtools show this kind of ā€œnoopā€ mutation quite elegantly. The details are slightly different but I think the idea still holds. Basically, they show you the action and the the whole new and old objects. However, they also have a separate view where you can see the ā€œdiffā€ between the new and old states:

This way, itā€™s more clear and predictable. If the developer was overwriting state.user, we shouldnā€™t try to second-guess their intention :slight_smile:

So, in the case of this mutation:

const state = { user: { name: "Jon" } };

// Mutation 1:
state.user.name = "Ned"; 
// Mutation 2:
state.user = { name: "Ned" }; 

what would happen in Mutation 2. is that the computation is triggered, but we would have an ā€œemptyā€ diff so to speak.

Which actually brings me to an interesting idea that we could capture the mutations that result in an ā€œemptyā€ diff because those are mutations that would result in an unnecessary rerender and possibly warn the user about this !!!

EDIT: As a counterpoint, seems like Mobx-State-Tree for example does not work like this and does not trigger mutations if the references have changed but the actual values are the same.

I really like your idea. Actually, if we do the deep-diff in the devtools and we also track which components are listening to which parts of the state, the devtools would be able to tell the developer about all the unnecessary rerenders that could be optimized.

Itā€™s still not as good as making everything optimized by default but pretty good for our first version :smile::smile:

Ok, this means we can simple work on adding middleware support for the current implementation, right?

Okay, great. So, letā€™s try to zero in on the API that we want.

I have previously suggested something like (the naming is not set in stone yet of course) :slight_smile: :

const loggerExampleMiddleware = async ( ctx, state, actions, next, abort, resume ) => {
  if (ctx.isAborted) {
    resume(); // we can check if the current middleware has 
              // been aborted by previous middleware
  }
  if (!ctx.path === 'user.name') {
    return next();
    // We can bail out or running the middleware if we want to
  }
  
  console.log(ctx.patch) {
  // If this is a state mutation, we can have a patch
  
  if (ctx.path === 'user.email') {
    return abort();
    // If we abort, the middleware:
    // - stops executings here (we have to explicitly return)
    // - aborts all subsequently scheduled middlewares 
    //   (for the particular node in the tree / action).
    // - aborts any actions called from the current action.
  }

  console.log(`action ${ctx.name} will run`));

  await next(); 
  // - In the case of mutation middleware, we don't have to await
  //   because the state mutation is synchronous.
  // - In the case of action middleware, we always have to await, 
  //   because the action could be async (we don't know it if is)
  
  abort("optional value"); 
  // Aborting is still allowed after we run the action! 
  // In this case the action all subsequent middlewares are aborted!
}

Then we can attach this middleware like:

addMiddleware({
  name: 'logger-middleware', // optional
  priority: 10,              // optional, default: 10
  test: ({ state }) => state.user.name,
  reaction: myLoggerMiddleware
});
type middlewareContext = {
  name: string
  path: string
  patch: middlewarePatch // this type is not defined yet
  isAborted: Boolean
  abortedBy: string?
  abort: () => {}
}

Notes

I am not 100% sure if this is technically possible but would be great if calling next() could be optional. What I mean is this: If next() IS called inside of the middleware, then the middleware is an async function that behaves as described in the snippet.

However, if it is NOT called, then it is a simple ā€œreactionā€, which runs after the state was mutated / action called

Nice to haves:

  • stringly path selectors,
{
  test: ({ state }) => "state.user.name"
}

but think we can add the selection by property first

By the way, I think this should not be an issue? I thought that

state.arr[1].value === 2 // <- false

Since we are not doing a deep overwrite anymore ? Or did I misunderstand this?

At least we need to support two use cases with middleware: devtools and filters.

Letā€™s see an example of a filter: The user wants to add a "@" in front of the user name each time the user name is edited.

It has work for both direct primitive mutations and parent object mutations:

state.user.name = "Jon";
state.user = { name: "Jon" };

Could you please provide an example with your API suggestion of how the user would create this filter?

What I mean by that is that if we allow the same reference to be present in more than one node of the state, it is the same reference only before the serialization, but itā€™s a different reference after the serialization.

Itā€™s probably best showed with an example: https://codesandbox.io/s/agitated-faraday-6jpsy

EDIT: I should mention that, in Frontity, actions work both in the server first and then in the client so an action expecting to have the same reference would work in the server but not in the client.

Ah, okay, Thatā€™s the crucial point that I was missing :slight_smile: This complicates things a bit. Does the middleware also run both on the server and the client?

OK, so Iā€™m guess it could be simply something like:

const primitiveMiddleware = async (ctx, state, next) => {
  if (state.user.name.startsWith("@")) { 
    next();
  }
  state.user.name = "@" + state.user.name;

  // this is maybe optional if the middleware can be "smart" 
  // enough to know to run the mutation automatically if `next()`
  //  is not present anywhere in the body of the middleware.
  await next(); 
}

But perhaps you already notice, Luis, that have to be careful here. The state cannot be the proxified state because then we will be stuck, re-triggering the middleware in an infinite loop. So, I guess we have to create a new proxy for the raw state for each middleware callā€¦

For the sake of completeness, the example for the object mutation:

const objectMiddleware = async (ctx, state, next) => {
  // ...
  state.user = { name: "@" + state.user.name };
  await next(); 
}

And attach it:

addMiddleware({
  test: ({ state }) => state.user.name,
  reaction: primitiveMiddleware
})

Things the we will work out as we go along:

  • the order and number or arguments passed to the callbacks: ctx, state, next, etc. is up for debate. also should they be an object or plain function arguments

Ok. Iā€™m starting to see that not having ā€œatomic and deterministic mutationsā€ is going to limit the things we expose to the middleware callbacks. Letā€™s see if we can circumvent that.

Letā€™s keep using the same example. We have these mutations:

// Mutation 1
state.user.name = "Jon"; 
// Mutation 2
state.user = { name: "Jon", surname: "Snow" };
// Mutation 3
state = { user: { name: "Jon", surname: "Snow" } };

And we want to create a single filter to modify state.user.name.

@mmczaplinski proposal is:

addMiddleware({
  test: ({ state }) => state.user.name,
  reaction: ({ state, next }) => {
    if (state.user.name.startsWith("@"))
      return next();
    state.user.name = "@" + state.user.name;
  }
});

This seems to work fine for all the possible mutations.

Letā€™s keep doing examples of more problematic use cases.

Only track real changes

Create a middleware that sends an analytics event each time ā€œnameā€ changes.

addMiddleware({
  test: ({ state }) => state.user.name,
  reaction: ({ actions }) => {
    actions.analytics.sendEvent("name is " + state.user.name);
  }
});

The problem here is similar to rerenders. Without atomic & deterministic mutations we could be sending wrong events if we do this type of mutation in our app:

state.user = { name: "Jon", surname: "1" }; // sends "name is Jon"
state.user = { name: "Jon", surname: "2" }; // sends "name is Jon"
state.user = { name: "Jon", surname: "3" }; // sends "name is Jon"
state.user = { name: "Jon", surname: "4" }; // sends "name is Jon"

It is the developer the one who would have to store the old value and compare it with the new:

let oldValue = null;

addMiddleware({
  test: ({ state }) => state.user.name,
  reaction: ({ actions }) => {
    if (oldValue !== state.user.name) {
      actions.analytics.sendEvent("name is " + state.user.name);
      oldValue = state.user.name;
    }
  }
});

Now it works:

state.user = { name: "Jon", surname: "1" }; // sends "name is Jon"
state.user = { name: "Jon", surname: "2" }; // doesn't send anything
state.user = { name: "Jon", surname: "3" }; // doesn't send anything
state.user = { name: "Jon", surname: "4" }; // doesn't send anything

But itā€™s something developers need to notice, learn about and correct themselves, which detriments their developer experience.

By the way, this could be a computation instead of a middleware, but we have the same problem.

Last but not least, we cannot pass down the oldValue variable to the middleware, because without atomic & deterministic mutations itā€™s going to vary:

state.user.name = "Zac";  // oldValue: "Jon"
state.user = { name: "Zac", surname: "Snow" }; // oldValue: { name: "Jon", surname: "Snow" }
state = { user: { name: "Jon", surname: "Snow" } }; // oldValue: { user: { name: "Jon", surname: "Snow" } }

So itā€™ll be not only useless but confusing as well.

Aborting

Create a middleware that aborts the mutation if ā€œnameā€ starts with Z.

addMiddleware({
  test: ({ state }) => state.user.name,
  reaction: ({ state, abort }) => {
    if (state.user.name.startsWith("Z"))
      abort();
  }
});

Here, not having atomic mutations is also a problem. This is going to work fine for mutation 1:

// Mutation 1
state.user.name = "Zac";

but what happens with mutation 2 and 3? Are we going to abort the whole mutation just because of some internal prop to the object?

// Mutation 2
state.user = { name: "Zac", surname: "Legit Surname" };
// Mutation 3
state = { user: { name: "Zac", surname: "Legit Surname" } };

state.user.surname should be updated to "Legit Surname" but it is going to be aborted because state.user.name starts with "Z".

I guess the only way to solve this is to, again, store the oldValue and restore it, instead of aborting:

let oldValue = null;

addMiddleware({
  test: ({ state }) => state.user.name,
  reaction: ({ state }) => {
    if (state.user.name.startsWith("Z"))
      state.user.name = oldValue;
    else
      oldValue = state.user.name;
  }
});

But we would lose the ability to stop executing subsequent middleware after ā€œthe abortionā€.

Even if we try to solve that with an abort() function like this:

let oldValue = null;

addMiddleware({
  test: ({ state }) => state.user.name,
  reaction: ({ state, abort }) => {
    if (state.user.name.startsWith("Z")) {
      state.user.name = oldValue;
      abort();
    } else {
      oldValue = state.user.name;
    }
  }
});

it doesnā€™t make much sense because you may be aborting middleware that only cares about state.user.surname:

state.user = { name: "Zac", surname: "Legit Surname" }; // Aborts everything.

We can pass path and patch to the middleware and it would be useful for devtools, but I think we have to be careful because itā€™s going to be confusing for filters, similar to what happens with oldValue:

// Mutation 1
state.user.name = "Jon"; 
// Mutation 2
state.user = { name: "Jon", surname: "Snow" };
// Mutation 3
state = { user: { name: "Jon", surname: "Snow" } };

addMiddleware({
  test: ({ state }) => state.user.name,
  reaction: ({ path, patch }) => {
    // For mutation 1:
    path === "state.user.name";
    patch === { type: "set", value: "Jon" };
    // For mutation 2:
    path === "state.user";
    patch === { type: "set", value: { name: "Jon", surname: "Snow" } };
    // For mutation 3:
    path === "state";
    patch === { type: "set", value: { user: { name: "Jon", surname: "Snow" } } };
  }
});

So I think that without atomic and deterministic mutations we have to stick to an API quite similar to a normal computation.