Building an app with Cycle.JS - Creating components

I have decided to document my process of trying to build a real web application with Cycle.js. For me the best way to learn something is to dive in and try to experience as many "everyday" scenarios as I can.

I am going to build a simple Github repository viewer using the v3 public api.

In this first post I am going to focus on taking an example from earlier and refactoring that into a component that can be shared and re-used with Cycle.js.

The example I augmented in my last post was an autocomplete search box allowing you to search github repositories by name. All of the code currently is in a single main function so we are going to work through refactoring this into a component that follows the Model-View-Intent patterns.

I am still learning and experimenting so please do not take my code as perfect. It is simply my interpretation of how to isolate the code into a readable and maintainable component. Pull requests and comments are always welcome!

Intent

So lets start with the I in MVI which stands for Intent. We are going to write an intent() function that will take in the sources from Cycle.js and combine them with any user interactions we are interested in. We will then return an object that exposes Observables of these interactions.

In our component we are interested in listening to changes on an input field. We can do this by using the DOM driver as seen below.

const inputFieldChange$ = DOM.select(".search-field")
  .events("input")
  .debounce(500)
  .map((ev) => ev.target.value);

We then want to take that user interaction and transform it into a URL that can be passed to the HTTP driver. We do this using our previous observable and mapping the output to a URL only when the input field has a value.

const searchRequest$ = inputFieldChange$
  .filter((query) => query.length > 0)
  .map((q) => GITHUB_SEARCH_API + encodeURI(q));

We have our user interaction taken care of so now we just return an object that contains both of these events. Our complete intent function is below.

const GITHUB_SEARCH_API = "https://api.github.com/search/repositories?q=";

function intent(DOM) {
  const inputFieldChange$ = DOM.select(".search-field")
    .events("input")
    .debounce(500)
    .map((ev) => ev.target.value);

  const searchRequest$ = inputFieldChange$
    .filter((query) => query.length > 0)
    .map((q) => GITHUB_SEARCH_API + encodeURI(q));

  return {
    queryChange$: inputFieldChange$,
    request$: searchRequest$,
  };
}

We now need to wire this Intent function into Cycle.js. We start with our component constructor and pass along the DOM driver. We also take our request$ and pass it to the HTTP for processing. Later we will see how to handle the responses from the HTTP driver. Note we also export the constructor so that we can eventually use it later.

function RepoComponent(sources) {
  const actions = intent(sources.DOM);
  const state$ = null; // To be done later
  const vtree$ = null; // To be done later

  return {
    DOM: vtree$,
    HTTP: actions.request$,
  };
}

export default RepoComponent;

Model

We now turn to the M in MVI which is our Model. The model responds to our intents and other sources to transform data into our state. The model function will then return an object containing observables of our desired state.

We start by creating a model function that takes in both the actions created by our intent(DOM) function and the HTTP driver from Cycle.js.

In our model we need to represent a few potential states.

  • Empty (displays no results and no message)
  • Loading (displays no results and a loading message)
  • Error (displays no results and an error message)
  • OK (displays the results with no message)

To do this we will transform the output of the HTTP driver by doing the following

  • Listen to the HTTP driver and filter out the requests we made using filter()
  • Transform that observable by using flatMap()
  • At the start of every request we want to show the Loading state using startWith
  • Ensure that we catch any errors and return the Error object using catch
  • We want to show the empty state when the input of the text field is 0 using merge
  • We want to map the successful response into the OK state using map. During this step we parse out the objects we want
  • Finally we want to start the sequence with the empty state using startWith
const LOADING = { msg: "Loading...", results: [] };
const EMPTY = { msg: "", results: [] };

function model(actions, HTTP) {
  const response$ = HTTP.filter(
    (res$) => res$.request.url.indexOf(GITHUB_SEARCH_API) === 0
  )
    .flatMap((x) =>
      x.startWith(LOADING).catch((err) => Observable.just({ err }))
    )
    .merge(actions.queryChange$.filter((query) => query.length == 0).map(EMPTY))
    .map(
      isResult(({ body, results, msg }) => {
        //If we got a response and there are no results then set a 'no results' message
        if (body && body.items.length == 0) {
          msg = "No Results";
        }
        return { results: body ? body.items : results, msg: msg || "" };
      })
    )
    .startWith(EMPTY);

  return response$;
}

View

The last thing we want to do is actually display our model. For this we will create the V in MVI which is the view function. Our view needs to be able to render a few items.

  • The input field we want to observe
  • The list of results
  • The message if available

We will break this into two distinct observables. The first will render the input field. The second will render the results and message. Finally we return a single Observable that combines the latest result of each.

function view(state$) {
  const refreshButton$ = Observable.just(renderInputField());

  const results$ = state$
    .map(isResult(({ msg, results }) => renderResults(msg, results)))
    .map(isErr(({ err }) => renderError(err)));

  return Observable.combineLatest(
    refreshButton$,
    results$,
    (button, results) => {
      return (
        <div>
          {button}
          {results}
        </div>
      );
    }
  );
}

function renderResults(msg, results) {
  return (
    <div>
      {msg.length > 0 ? <div className="message">{msg}</div> : null}
      <ul className="results">
        {results.map((obj) => (
          <li id={obj.id} className="repo-name">
            {obj.name}
          </li>
        ))}
      </ul>
    </div>
  );
}

function renderError(err) {
  return <p>{err.message}</p>;
}

function renderInputField() {
  return (
    <div>
      <label className="label">Search</label>
      <input type="text" className="search-field" />
    </div>
  );
}

One thing here that I am a little unsure of is the best way to switch between map functions based on if the object is an error or a result. I found some interesting helper functions written by killercup and they can be found on github. These functions inspect the incoming object and determine whether to run the mapping function passed in - if not then the object is passed along to the next function in the sequence. I am using these here since they are simple and work very well in this scenario. I would love some feedback on what others have done here.

Rounding out the component

The last thing we need to do is update the constructor and wire the model and view together. Our final code gets a reference to our state and passes that along to the view method. We then take the return value of our view method an pass it along to the Cycle.js DOM driver for rendering.

function RepoComponent(sources) {
  const actions = intent(sources.DOM);
  const state$ = model(actions, sources.HTTP);
  const vtree$ = view(state$);

  return {
    DOM: vtree$,
    HTTP: actions.request$,
  };
}

You can checkout the complete example and source code on Github

We now have a re-usable component to use in a web application. In my next post we will explore how to use this component in a larger web application.