Skip to content

Instantly share code, notes, and snippets.

@luisherranz
Last active June 24, 2019 02:13
  • Star 7 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save luisherranz/afc77fe8e74e06dd0ed666a118d5b0ce to your computer and use it in GitHub Desktop.
Proposal for a redux-like API on top of Mobx

I'd got rid of action types and I'd have only actions (action creators). No strings, the function (reference) is used later for comparisons.

export const addTodo = (id, title) => ({ id, title });
export const removeTodo = id => ({ id });
actions({ addTodo, removeTodo }); // Connect it to actions.addTodo & actions.removeTodo.

That's it. They define what your app can do.

Reducers are similar to redux reducers, but more flexible because they can depend on actions or other state. They can mutate state (because in Mobx that's not a drawback).

export const todoList = ({ action, actions, state }) => {
  switch(action) {
    case actions.$addTodo:
      state.push({ id: action.id, title: action.title, completed: false });
      return state;
    case actions.$removeTodo:
      state.filter(todo => todo.id !== action.id);
      return state;
  }
};
state({ todos: { list: todoList } }); // Connect it to state.todos.list.

The .props property can be used for dependency injection and therefore easy testing. You could either user it or not:

export const isLogging = ({ action, $request, $success, $failure }) => {
    if (action === $request)
        return true;
    else if (action === $success || action === $failure)
        return false;
};
isLogging.props => ({ actions }) => ({
    $request: actions.$loginRequest,
    $success: actions.$loginSuccess,
    $failure: actions.$loginFailure,
});
state({ accounts: { isLogging } }); // Connect it to state.accounts.isLogging.

Thanks to Mobx, this function won't be triggered unless $loginRequest, $loginSuccess or $loginFailure are dispatched, so you can simplify it even more:

export const isLogging = ({ action, $request }) => action === $request;

isLogging.props => ({ actions }) => {
  actions.$loginSuccess;
  actions.$loginFailure;
  return { $request: actions.$loginRequest };
};

There is no need to return state by default in your reducers, because they won't be executed unless something they are observing changes.

All actions and state would have two versions, the observable and the non-observable. You could use the observable one with the $ symbol. This would give us extremely flexibility when writing logic and easy-to-read code.

For example, it's really easy to see that in this app we want to check the health of an API each time a new site is selected:

import { fetchUrl } from 'fetch';

export const checkApiStatus = ({ action, actions, url, fetch }) => {
    if (action === actions.$newSiteSelected)
        fetch(url + '/api/check')
            .then(res => actions.fetchApiSuccess(res.body))
            .catch(error => actions.fetchApiFailure(error))
};
checkApiStatus.props = ({ state }) => ({
    fetch: fetchUrl, // fetchUrl is passed using DI for easy mocking in the tests.
    url: state.site.url, // non-observable
});

But in this other app we want to check the health of an API each time the url of the site is changed:

export const checkApiStatus = ({ $url, fetch }) => {
  fetch($url + '/api/check')
    .then(res => actions.fetchApiSuccess(res.body))
    .catch(error => actions.fetchApiFailure(error))
};
checkApiStatus.props = ({ state }) => ({
    fetch: fetchUrl,
    $url: state.site.$url, // observable
});

This thunk-like side-effect functions could be called reactions and would be connected in the same way to keep coherence, although they don't need to be accessed later:

reactions({ checkApiStatus });

Selectors (reselect) would disappear as they become first citizens of the state system. For example, this selectedTodo reducer would update each time selectedTodoId or todos states update.

export const selectedTodo = ({ $id, $todos }) => {
  const index = _.findIndex($todos, todo => todo.id === $id);
  return index !== -1 ? $todos[index] : {};
};
selectedTodo.props = ({ state }) => ({ 
  $id: state.todos.$selectedTodoId,
  $todos: state.todos.$list,
});
state({ todos: { selectedTodo } }); // Connect it with state.todos.selectedTodo.

The incredible thing here is that Mobx would take care of the execution order of all the system for us, thanks to their dependency graph.

Finally, although redux-saga is really brilliant, I think a redux-saga-like API could be written using async/await instead of generators, once again for easier-to-read (or reason about) code. For example:

import { saga } from '...our lib';

export async function incrementAsync({ actions, saga, delay }) {
  if (actions.$incrementAsync) {
    await saga.call(delay, 1000);
    saga.dispatch(actions.increment);
  }
}
incrementAsync.props = () => ({
  saga: saga, // imported from our framework utility
  delay: ms => new Promise(resolve => setTimeout(resolve, ms)),
});

Thanks of the dependency injection of saga we can easily test this function with a different saga tester object.

import { SagaTester } from '...our lib';

test(t => {
  const actions = { $incrementAsync: true, increment: () => {} };
  const delay = {};
  const saga = new SagaTester();
  incrementAsync({ actions, delay, saga });
  t.deepEqual(saga[0], saga.call(delay, 1000));
  saga.resume();
  t.deepEqual(saga[1], saga.dispatch(actions.increment));
});

We could pass data back to the function using saga.resume(val) like we do with gen.next(val) in redux-saga. For example in things like this:

async function loginFlow({ actions, saga }) {
  while (true) {
    const {user, password} = await saga.waitFor(actions.loginRequest);
    const task = saga.task(authorize, user, password);
    const action = await saga.waitFor([actions.logout, actions.loginError]);
    if (action === actions.logout)
        saga.cancel(task);
    saga.call(Api.clearItem, 'token');
  }
}

Any feedback is welcomed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment