Composition

Most Javascript applications contain many components. Each of the components can have its own view, its own local state and run some asynchronous actions like API requests. We present how to compose isolated components in Redux Ship as in Elm, thanks to the Ship.map primitive. The code of this tutorial is in examples/http-request.

Screenshot

Components

We compose two components with some API calls to SWAPI:

  • eye which gets the color of the eye of R2-D2;
  • movies which gets the list of movies of R2-D2.

Each component has its own folder with three files:

  • model.js (the Redux part);
  • view.js (the React part);
  • controller.js (the Redux Ship part).

See src/eye and src/movies for the sources of the components.

Model

The model of the main application regroups the model of each component.

// @flow
import * as EyeModel from './eye/model';
import * as MoviesModel from './movies/model';

export type State = {
  eye: EyeModel.State,
  movies: MoviesModel.State,
};

export const initialState: State = {
  eye: EyeModel.initialState,
  movies: MoviesModel.initialState,
};

export type Commit = {
  type: 'Eye',
  commit: EyeModel.Commit,
} | {
  type: 'Movies',
  commit: MoviesModel.Commit,
};

export function reduce(state: State, commit: Commit): State {
  switch (commit.type) {
  case 'Eye':
    return {
      ...state,
      eye: EyeModel.reduce(state.eye, commit.commit),
    };
  case 'Movies':
    return {
      ...state,
      movies: MoviesModel.reduce(state.movies, commit.commit),
    };
  default:
    return state;
  }
}

The state of the main application is a plain object with two fields containing the states of the eye component and of the movies component. A commit of the main application is either a commit for the eye component or for the movies component. The job of the reducer is to call the right sub-reducer on the right sub-state.

View

The main view includes the view of each sub-component.

// @flow
import React, { PureComponent } from 'react';
import * as EyeController from './eye/controller';
import Eye from './eye/view';
import * as MoviesController from './movies/controller';
import Movies from './movies/view';
import * as Controller from './controller';
import * as Model from './model';

type Props = {
  dispatch: (action: Controller.Action) => void,
  state: Model.State,
};

export default class Index extends PureComponent<void, Props, void> {
  handleDispatchEye = (action: EyeController.Action): void => {
    this.props.dispatch({type: 'Eye', action});
  };

  handleDispatchMovies = (action: MoviesController.Action): void => {
    this.props.dispatch({type: 'Movies', action});
  };

  render() {
    return (
      <div>
        <h1>Eye</h1>
        <Eye
          dispatch={this.handleDispatchEye}
          state={this.props.state.eye}
        />
        <h1>Movies</h1>
        <Movies
          dispatch={this.handleDispatchMovies}
          state={this.props.state.movies}
        />
      </div>
    );
  }
}

The composition of the views is simplified by the use of React. However, we must be cautious to provide the correct props to the children components. We compute the state property by extracting the sub-state the component is interested in. We compute the dispatch property by lifting actions of the component to actions of the application. This brings use to the definition of the controller.

Controller

We compose the controllers of eye and movies in one controller for the whole application:

// @flow
import * as Ship from 'redux-ship';
import * as EyeController from './eye/controller';
import * as MoviesController from './movies/controller';
import * as Model from './model';

export type Action = {
  type: 'Eye',
  action: EyeController.Action,
} | {
  type: 'Movies',
  action: MoviesController.Action,
};

export function* control(action: Action): Ship.Ship<*, Model.Commit, Model.State, void> {
  switch (action.type) {
  case 'Eye':
    return yield* Ship.map(
      commit => ({type: 'Eye', commit}),
      state => state.eye,
      EyeController.control(action.action)
    );
  case 'Movies':
    return yield* Ship.map(
      commit => ({type: 'Movies', commit}),
      state => state.movies,
      MoviesController.control(action.action)
    );
  default:
    return;
  }
}

We define an application action as being either an action for the eye component or an action for the Movies component. The control function takes care to dispatch the right action to the right sub-controller. Because the sub-controllers does not have access to the same state and patches, we need to wrap them with the Ship.map primitive. Indeed, the ship:

EyeController.control(action.action)

is of type:

Ship.Ship<*, EyeModel.Commit, EyeModel.State, void>

but we want to return a controller of the following type:

Ship.Ship<*, Model.Commit, Model.State, void>

With the Ship.map primitive, we lift the eye controller to the right type:

Ship.map(
  commit => ({type: 'Eye', commit}),
  state => state.eye,
  EyeController.control(action.action)
)

We declare:

  • how to lift a commit of the eye controller to a commit of the application controller;
  • how to extract the state of the eye controller from the state of the application controller.

Snapshots

We get the following snapshot when clicking on the eye button:

[
  {
    "type": "Commit",
    "commit": {
      "type": "Eye",
      "commit": {
        "type": "LoadStart"
      }
    }
  },
  {
    "type": "Effect",
    "effect": {
      "type": "HttpRequest",
      "url": "http://swapi.co/api/people/3/"
    },
    "result": "{\"name\":\"R2-D2\",\"height\":\"96\",\"mass\":\"32\",\"hair_color\":\"n/a\",\"skin_color\":\"white, blue\",\"eye_color\":\"red\",\"birth_year\":\"33BBY\",\"gender\":\"n/a\",\"homeworld\":\"http://swapi.co/api/planets/8/\",\"films\":[\"http://swapi.co/api/films/5/\",\"http://swapi.co/api/films/4/\",\"http://swapi.co/api/films/6/\",\"http://swapi.co/api/films/3/\",\"http://swapi.co/api/films/2/\",\"http://swapi.co/api/films/1/\",\"http://swapi.co/api/films/7/\"],\"species\":[\"http://swapi.co/api/species/2/\"],\"vehicles\":[],\"starships\":[],\"created\":\"2014-12-10T15:11:50.376000Z\",\"edited\":\"2014-12-20T21:17:50.311000Z\",\"url\":\"http://swapi.co/api/people/3/\"}"
  },
  {
    "type": "Commit",
    "commit": {
      "type": "Eye",
      "commit": {
        "type": "LoadSuccess",
        "color": "red"
      }
    }
  }
]

The "type": "Effect" event is not changed by the composition. We can see that the "type": "Commit" events originate from the eye controller thanks to the "type": "Eye" field.

What we gain

By composing components in this way, we get isolated components which help reusability. As an illustration, the eye controller only knows about the eye state and the eye patches, so we are sure it cannot interact with other elements. We did compose the eye controller with the movies controller, but we could as well compose the eye controller with... itself:

// model.js
export type State = {
  first: EyeModel.State,
  second: EyeModel.State,
};

export type Commit = {
  type: 'First',
  commit: EyeModel.Commit,
} | {
  type: 'Second',
  commit: EyeModel.Commit,
};

// controller.js
export function* control(action: Action): Ship.Ship<*, Model.Commit, Model.State, void> {
  switch (action.type) {
  case 'First':
    return yield* Ship.map(
      commit => ({type: 'First', commit}),
      state => state.first,
      EyeController.control(action.action)
    );
  case 'Second':
    return yield* Ship.map(
      commit => ({type: 'Second', commit}),
      state => state.second,
      MoviesController.control(action.action)
    );
  default:
    return;
  }
}

This is important because a component (like a form input with auto-complete) may appear several times in one web page.

results matching ""

    No results matching ""