Connect Middleware

I see another problem of not having atomic & deterministic mutations.

Imagine we have two developers, one (dev A) is the creator or the "user" package and the other (dev B) is another package creator.

Imagine dev A has created a mutation inside his action like this:

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

and dev B has happily subscribed to state.user and everything works:

addMiddleware({
  test: ({ state }) => state.user,
  reaction: () => {
    // do stuff with state.user.name and state.user.surname
  }
});

(This could be a computation instead of middleware as wellā€¦)

Now, imagine dev A wants to update the "user" package and for whatever the reason, he decides to refactor his mutation to this:

state.user.name = "Jon";
state.user.surname = "Snow";

The code of dev B is going to stop working. The middleware wonā€™t be triggered anymore. It has to be refactored to this:

addMiddleware({
  test: ({ state }) => {
    state.user.name;
    state.user.surname;
  },
  reaction: () => {
    // do stuff with state.user.name and state.user.surname
  }
});

This is a problem because we will be violating semver. The small refactoring of dev A is not supposed to be a major version, but it breaks dev Bā€™s code.

Those unfortunately are both very good points, Luis :sweat_smile:

I was thinking how to solve this now. Maybe itā€™s possible to change the signature of our test function to fix both of them.

What I have in mind is: If you squint your eyes a bit, you can picture our middleware a bit like a react component. The reaction is the computation (the render function) and the test is a bit like shouldComponentUpdate. So, if instead of ā€œselectingā€ the state that we want to observe with our middleware, we have to explicitly compare it. Example:

Instead of:

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

We would have:

{
  test: ({ prevState, nextState }) => 
    prevState.user.name !== nextState.user.name && 
    prevState.user.surname !== nextState.user.surname
  }      
}

So, the user has to explicitly compare the state.

Great idea! That would certainly make the non-deep-diff implementation feasable and the API to make the subscriptions is easy to understand and use.

So I guess now we need to recap and decide to deep-diff or not to deep-diff. Iā€™ll do a summary of the pros and cons of each approach next week so we can take the best decision possible.

Letā€™s see if I can do a quick summary of our API options for computations (or at least the ones weā€™ve come up with so far).

React Computations

connect

This is one is working perfectly and easy to use:

const Comp = ({ state, actions, libraries }) => {
  return (
    // use state, actions and libraries in your component...
  );
};

export default connect(Comp);

I donā€™t see the need to change it or add an additional API.

State Computations

By state computations I mean ways to subscribe to state mutations.
I want this code to be executed when this part of the state changes.

observe (from react-easy-state)

This is the one that we have right now, inherited from react-easy-state. It is not documented, so we can deprecate it if needed:

observe(() => {
  actions.analytics.sendPageview(state.router.link);
});

One modification could be to pass proxified state and actions to the callback:

observe(({ state, actions }) => {
  actions.analytics.sendPageview(state.router.link);
});

The main advantage of passing the state to the computation is that we can use that information in the devtools.

reaction (from mobx)

It could be without passing state and actions:

reaction(
  () => state.router.link,
  () => actions.analytics.sendPageview(state.router.link)
);

It could pass the observed (returned) value. This is actually how it works in Mobx.

reaction(
  () => state.router.link,
  link => actions.analytics.sendPageview(link)
);

It could get state and actions in the calbacks:

reaction(
  ({ state }) => state.router.link,
  ({ state, actions }) => {
    actions.analytics.sendPageview(state.router.link);
  }
);

It could be an object instead of two functions and accept more params, like name or priority:

reaction({
  name: "triggerPageviews",
  test: ({ state }) => state.router.link,
  reaction: ({ state, actions }) => {
    actions.analytics.sendPageview(state.router.link);
  }
});

The main advantage of having a name is that we can use that in the devtools.

The test function

The test function (whether it lives in an object or not) can be a computation like this:

reaction({
  test: ({ state }) => state.router.link,
});

a function that gets the path and/or patch:

reaction({
  test: ({ path }) => path === "state.router.link",
});
reaction({
  test: ({ patch }) => patch.type === "set" && patch.path === "state.router.link",
});

a function that gets the current state and previous state:

reaction({
  test: ({ prevState, nextState }) =>
    prevState.router.link !== nextState.router.link,
});

or any combination of those.

Where do obseve or reaction could live?

The observe/reaction functions could live inside actions:

const triggerPageviews = ({ state, actions }) => {
  observe(() => {
    actions.analytics.sendPageview(state.router.link);
  });
};
const triggerPageviews = ({ state, actions }) => {
  reaction(
    () => state.router.link,
    () => actions.analytics.sendPageview(state.router.link)
  );
};

and uses would have the option to call that action whenever they see fit, for example, in the init action:

export default {
  state: { ... },
  actions: { 
    analytics:  {
      triggerPageviews,
      init: ({ actions }) => actions.analytics.triggerPageviews()
    }
  },
  libraries: { ... }
}

or initialized when Frontity loads:

  • in an array exposed by the packages:
export default {
  state: { ... },
  actions: { ... },
  libraries: { ... },
  reactions: [
    triggerPageviews
  ]
}
  • in an object with namespaces exposed by the packages:
export default {
  state: { ... },
  actions: { ... },
  libraries: { ... },
  reactions: {
    analytics: {
      triggerPageviews
    }
  }
}

waitFor (similar to when in mobx or take in redux-saga)

This promise could resolve when the state changes.

const triggerPageviews = async ({ state, actions }) => {
  while(true) {
    await waitFor(() => state.router.link);
    actions.analytics.sendPageview(state.router.link);
  }
};

It could return the part of the state that changed.

const triggerPageviews = async ({ state, actions }) => {
  while(true) {
    const link = await waitFor(() => state.router.link);
    actions.analytics.sendPageview(link);
  }
};

It could get the state in the callback.

const triggerPageviews = async ({ state, actions }) => {
  while(true) {
    const link = await waitFor(({ state }) => state.router.link);
    actions.analytics.sendPageview(link);
  }
};

Or we could get waitFor from the action, with a proper parent = triggerPageviews in the context.

const triggerPageviews = async ({ state, actions, waitFor }) => {
  while(true) {
    const link = await waitFor(({ state }) => state.router.link);
    actions.analytics.sendPageview(link);
  }
};

If we do this, it would be fairly easy to show in the devtools that the action triggerPageviews is being executed but has not finished and it is ā€œwaiting forā€ a change in state.router.link.

It could use the prevState and nextState syntax:

const triggerPageviews = async ({ state, actions }) => {
  while(true) {
    await waitFor(
      ({ prevState, nextState }) => prevState.router.link !== nextState.router.link
    );
    actions.analytics.sendPageview(state.router.link);
  }
};

Action Computations

By action computations I mean ways to subscribe to action executions.
I want this code to be executed when this action is executed.

reaction (like mobx)

It could be without passing state and actions:

reaction(
  () => actions.router.set,
  () => actions.analytics.sendPageview(state.router.link)
);

It could get state and actions in the callbacks:

reaction(
  ({ actions }) => actions.router.set,
  ({ state, actions }) => {
    actions.analytics.sendPageview(state.router.link);
  }
);

It could be an object instead of two functions and accept more params, like name or priority:

reaction({
  name: "triggerPageviews",
  priority: 10,
  test: ({ actions }) => actions.router.set,
  reaction: ({ state, actions }) => {
    actions.analytics.sendPageview(state.router.link);
  }
});

The test function

The test function (whether it lives in an object or not) can be a computation like this:

reaction({
  test: ({ actions }) => actions.router.set,
});

a function that gets the path:

reaction({
  test: ({ path }) => path === "actions.router.set",
});

or any combination of those.

Where do reaction could live?

The reaction functions could live inside other actions:

const triggerPageviews = ({ state, actions }) => {
  reaction(
    () => actions.router.set,
    () => actions.analytics.sendPageview(state.router.link)
  );
};

and uses would have the option to call that action whenever they see fit, for example, in the init action:

export default {
  state: { ... },
  actions: { 
    analytics:  {
      triggerPageviews,
      init: ({ actions }) => actions.analytics.triggerPageviews()
    }
  },
  libraries: { ... }
}

or initialized when Frontity loads:

  • in an array exposed by the packages:
export default {
  state: { ... },
  actions: { ... },
  libraries: { ... },
  reactions: [
    triggerPageviews
  ]
}
  • in an object with namespaces exposed by the packages:
export default {
  state: { ... },
  actions: { ... },
  libraries: { ... },
  reactions: {
    analytics: {
      triggerPageviews
    }
  }
}

waitFor (similar to when in mobx or take in redux-saga)

This promise could resolve when the action is executed changes.

const triggerPageviews = async ({ state, actions }) => {
  while(true) {
    await waitFor(() => actions.router.set);
    actions.analytics.sendPageview(state.router.link);
  }
};

It could return the array of arguments of the action that was executed.

const triggerPageviews = async ({ state, actions }) => {
  while(true) {
    const [link] = await waitFor(() => actions.router.set);
    actions.analytics.sendPageview(link);
  }
};

It could get the actions in the callback.

const triggerPageviews = async ({ state, actions }) => {
  while(true) {
    const [link] = await waitFor(({ actions }) => actions.router.set);
    actions.analytics.sendPageview(link);
  }
};

Or we could get waitFor from the action, with a proper parent = triggerPageviews in the context.

const triggerPageviews = async ({ state, actions, waitFor }) => {
  while(true) {
    const [link] = await waitFor(({ actions }) => actions.router.set);
    actions.analytics.sendPageview(link);
  }
};

If we do this, it would be fairly easy to show in the devtools that the action triggerPageviews is being executed but has not finished and it is ā€œwaiting forā€ an execution of the action actions.router.set.


Iā€™ll do a similar summary for the middleware APIs.

1 Like

Summary of the discussion around middleware and reactions

  • Middleware and reactions should be two separate APIs (work the middleware to be done first)
  • Should middleware and reactions be two implementations? (undecided yet)
  • Depending on whether we go with the deep-diff approach or not, the API for the test function should be (more or less):

no deep diff:

addMiddleware(
  (prev, next) => prev.router.link !== next.router.link,
  ({ prevState, state }) => {
    state.router.link = prevState.router.link; 
  }
)

with deep diff:

addMiddleware(
  ({ state }) => state.router.link,
  ({ abort }) => {
    state.source.something
    abort();
  })
  • The ā€œcallbackā€ of the middleware will be either something like:
 ({ abort, next, actions, state }) => {
     actions.analytics.sendPageview(state.router.link);
  },

or the waitFor / mobx when pattern discussed earlier

Here are the notes and examples for the final draft of the API.

Problems that we want to solve / avoid

  • rerendering problem (automatic optimal shouldComponentUpdate)
  • broken references problem
    the user can assign
state = { user: { name: 'Jon', lastName: 'Snow' } };
state.user.lastName = 'Targeryean';
// or
state.user = { lastName: 'Trump' }

and

  • we want to only run the computations (middleware/observers) for lastName

Open questions:

  • Should we return the state from the when() ?
const foobar = async ({ state, actions }) => {
  while(true) {
    const result = await when(({ prevState, state }) => state.user.name);
    // Do we need the `result` or should ?
  }
};
  • Do we need to pass the state and action both in the when and in the ā€œtop-levelā€ of the middleware or only from the when like:
const foobar = async ({ state, actions }) => { //<--- do we need the state, actions here?
  while(true) {
    const result = await when(({ prevState, state }) => state.user.name);
  }
};
  • Should when be a passed by the middleware like:
const foobar = async ({ state, actions, when }) => { //<--- do we need the `when` here?
  while(true) {
    const result = await when(({ prevState, state }) => state.user.name);
  }
};

or can it be imported from another module?

1 Like