A few of my favorite things

1/29/19

2019 edition

MobX State Tree and React Hooks

MobX State Tree

What is MobX?

simple scalable state-management

https://github.com/mobxjs/mobx

Simple compared to what?

// Actions
let nextTodoId = 0;
export const addTodo = text => ({
  type: "ADD_TODO",
  id: nextTodoId++,
  text
});

export const setVisibilityFilter = filter => ({
  type: "SET_VISIBILITY_FILTER",
  filter
});

export const toggleTodo = id => ({
  type: "TOGGLE_TODO",
  id
});

// Reducer
export const VisibilityFilters = {
  SHOW_ALL: "SHOW_ALL",
  SHOW_COMPLETED: "SHOW_COMPLETED",
  SHOW_ACTIVE: "SHOW_ACTIVE"
};

const todos = (state = [], action) => {
  switch (action.type) {
    case "ADD_TODO":
      return [
        ...state,
        {
          id: action.id,
          text: action.text,
          completed: false
        }
      ];
    case "TOGGLE_TODO":
      return state.map(todo =>
        todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
      );
    default:
      return state;
  }
};

export default todos;

Redux

import { observable, action } from "mobx";

class TodoStore {
  @observable todos = [];

  @action
  addTodo(todo) {
    this.todos.push(todo);
  }

  @action
  removeTodo() {
    this.todos.remove(todo);
  }
}

What did we gain?

Freedom

Less Boilerplate

Observable Data Flow (with actions)

What did we lose?

Opinions

What is MobX State Tree?


Opinionated, transactional, MobX powered state container combining the best features of the immutable and mutable world for an optimal DX

https://github.com/mobxjs/mobx

import { types } from "mobx-state-tree";

const Todo = types
  .model("Todo", {
    title: types.string,
    done: false
  })
  .actions(self => ({
    toggle() {
      self.done = !self.done;
    }
  }));

const Store = types.model("Store", {
  todos: types.array(Todo)
});

// create an instance from a snapshot
const store = Store.create({
  todos: [
    {
      title: "Get coffee"
    }
  ]
});

What did we lose?

More boilerplate

Less Freedom

Larger bundle (mobx, mobx-react, mobx-state-tree)

What did we gain?

Strong types (checked at runtime)

Single State Tree (similar to Redux)

Tools and Helpers

Snapshots

import { types, getSnapshot, applySnapshot } from "mobx-state-tree";

// Todo Model
const Todo = types
  .model("Todo", {
    title: types.string,
    done: types.boolean
  })
  .actions(self => ({
    toggle() {
      self.done = !self.done;
    }
  }));

// Editor
class TodoEditor {
  editTodo(todo) {
    this.initialState = getSnapshot(todo);
    this.todo = todo;
  }

  cancel() {
    applySnapshot(this.initialState, this.todo);
  }
}

Snapshots and SSR

getSnapshot(globalStore);

NodeJS

Browser

applySnapshot(globalStore, snapshot);

Dependency Injection

import { getEnv } from 'mobx-state-tree';


const UserStore = types
  .model("UserStore", {
    user: User
  })
  .actions(self => {
    login: async user => {
      // Get the Axios library from HTTP
      const result = await getEnv().axios.post("/login", user);
      self.user = result;
    };
  });


// Create axios singleton
const axiosInstance = axios.create({
  baseURL: 'https://some-domain.com/api/',
  timeout: 1000,
  headers: {'X-Custom-Header': 'foobar'}
});


// Supply entire state tree with axios
const Store = types.model("Store", {
  userStore: UserStore,
  todosStore: TodosStore
}, {
  axios: axiosInstance
});

Tree helpers

import { observable, action } from "mobx";

class TodoStore {
  @observable todos = [];

  @action
  addTodoForUser(user, todo) {
    this.todos.push(todo);
  }

  @action
  removeTodo() {
    this.todos.remove(todo);
  }
}

class UserStore {
  @observable user;

  @action
  login(user) {
    this.user = user;
  }
}

// Some UI Control

class TodoUI {
  constructor() {
    this.authStore = new UserStore();
    this.todoStore = new TodoStore();
  }

  createTodoForUser() {
    this.todoStore.addTodo(user, new Todo());
  }
}
import { getRoot } from "mobx-state-tree";

const TodosStore = types
  .model("TodosStore", {
    todos: types.array(Todo)
  })
  .actions(self => ({
    addTodoForUser(todo) {
      const user = getRoot(self).userStore.user;
      todos.push(todo);
    }
  }));

const UserStore = types
  .model("UserStore", {
    user: User
  })
  .actions(self => ({
    login(user) {
      self.user = user;
    }
  }));

const Store = types.model("Store", {
  userStore: UserStore,
  todosStore: TodosStore
});

// Some UI Control

class TodoUI {
  constructor() {
    this.store = new Store();
  }

  createTodoForUser() {
    this.store.todos.addTodoForUser(new Todo());
  }
}

vs.

Destroy

class TodosStore {
  @observable
  todos = [];

  @action
  removeTodo(todo) {
    const idx = this.todos.indexOf(todo);
    this.todos.splice(idx, 1);
  }
}
class TodosStore {
  @observable
  todos = [];

  @action
  removeTodo(todo) {
    this.todos = this.todos.filter(t => t.id === todo.id);
  }
}
import { destroy } from "mobx-state-tree";

const TodosStore = types
  .model("TodosStore", {
    todos: types.array(Todo)
  })
  .actions(self => ({
    removeTodo(todo) {
      destroy(todo);
    }
  }));

Hooks with Mobx

What are Hooks?

https://reactjs.org/docs/hooks-intro.html

Hooks are an upcoming feature that lets you use state and other React features without writing a class

Hooks are a better primitive to express intent and share behavior

import React from "react";
import MousePosition from "react-mouse-position";

export default React.createClass({
  mixins: [MousePosition],
  render() {
    return (
      <div>
        <span>X: {this.state.x}</span>
        <span>Y: {this.state.y}</span>
      </div>
    );
  }
});

Mixins

e.g. class implements A, B, C, D

  • Namespace collisions
  • Hard to debug, understand
  • Implicit dependencies
import React from "react";
import MousePosition from "react-mouse-position";

class TraceMouse extends React.Component {
  render() {
    const { x, y } = this.props;
    return (
      <div>
        <span>X: {x}</span>
        <span>Y: {y}</span>
      </div>
    );
  }
}

// Where do we get MousePosition?
export default MousePosition(TraceMouse);

Higher Order Components

e.g. (Component: T) => React.Component<T>

  • Namespace issues on injected props
  • Hard to write types for
  • Creates Intermediate components
import React from "react";
import MousePosition from "react-mouse-position";

export default class TraceMouse extends React.Component {
  render() {
    const { x, y } = this.props;
    return (
      <div>
        <MousePosition>
          {(x, y) => (
            <>
              <span>X: {x}</span>
              <span>Y: {y}</span>
            </>
          )}
        </MousePosition>
      </div>
    );
  }
}

Render Props

  • Creates Intermediate components
  • False hierarchy in the code
  • Messy Syntax
import React from "react";
import { useMousePosition } from "react-mouse-position";

export function TraceMouse() {
  const { x, y } = useMousePosition();

  return (
    <div>
      <span>X: {x}</span>
      <span>Y: {y}</span>
    </div>
  );
}

Hooks

  • No Intermediate components
  • No False hierarchy in the code
  • Better syntax*
  • Eliminates (this) problem in many cases
  • Minifies better

Combining with Mobx

// @flow
import React, { Fragment, useState, useContext } from "react";
import { observer } from "mobx-react-lite";
import { StoreContext } from "lib/stores";

export const CommitTable = (props: Props) => {
  const { authStore } = useContext(StoreContext);
  const [confirming, setConfirming] = useState(null);
  const [error, setError] = useState(null);

  const onPromoteRequested = (jiraCommit: JiraCommit) => {
    setConfirming(jiraCommit);
    setError(null);
  };

  const onPromoteConfirmed = async (jiraCommit: JiraCommit) => {
    try {
      await props.repoStore.promote(jiraCommit, authStore.token);
    } catch (e) {
      setError(e);
    } finally {
      setConfirming(null);
    }
  };

  // omitted for brevity
  return <div />;
};

export default observer(CommitTable);
// @flow
import React, { Fragment, Component } from "react";
import { inject, observer } from "mobx-react";

export class CommitTable extends Component<Props, State> {
  state = {
    confirming: null,
    error: null
  };

  onPromoteRequested = (jiraCommit: JiraCommit) => {
    this.setState({ confirming: jiraCommit, error: null });
  };

  onPromoteConfirmed = async (jiraCommit: JiraCommit) => {
    try {
      await this.props.repoStore.promote(jiraCommit, this.props.authStore.token);
    } catch (e) {
      this.setState({ error: e });
    } finally {
      this.setState({ confirming: null });
    }
  };

  onPromoteCancelled = () => {
    this.setState({ confirming: null });
  };

  onDismissErrorMessage = () => {
    this.setState({ error: null });
  };

  render() {
    // omitted for brevity
    return <div />;
  }
}

export default inject("authStore", "repoStore")(observer(CommitTable));

Current

Hooks

// @flow
import React, { Fragment, useContext } from "react";
import { observer, useObservable } from "mobx-react-lite";
import { StoreContext } from "lib/stores";

export const CommitTable = (props: Props) => {
  const { authStore } = useContext(StoreContext);
  const confirming = useObservable(null)
  const error = useObservable(null);

  const onPromoteRequested = (jiraCommit: JiraCommit) => {
    confirming = jiraCommit;
    error = null;
  };

  const onPromoteConfirmed = async (jiraCommit: JiraCommit) => {
    try {
      await props.repoStore.promote(jiraCommit, authStore.token);
    } catch (e) {
      setError(e);
      error = e;
    } finally {
      confirming = null;
    }
  };

  // omitted for brevity
  return <div />;
};

export default observer(CommitTable);

Hooks + mobx-react-lite

Bundle size results

206KB

---->

157KB

🔥

😨

Text

We are here

Phi's Law

Questions / Comments?

Favorite things

By Charles King

Favorite things

2019

  • 629