Example #4 - Github

In this guide we are going to build a component that asks for an username and then fetches all the repositories for that user on Github.

The final result will look like this:

Search for a github user

Found 11 repositories for user keajs!
keajs/kea - 1376 stars, 52 forks.
keajs/kea-website - 7 stars, 8 forks.
keajs/kea-example - 5 stars, 2 forks.
keajs/kea-on-rails - 5 stars, 0 forks.
keajs/kea-localstorage - 2 stars, 0 forks.
keajs/kea-saga - 2 stars, 3 forks.
keajs/kea-parallel - 2 stars, 0 forks.
keajs/kea-cli - 1 stars, 1 forks.
keajs/kea-parallel-loader - 0 stars, 0 forks.
keajs/kea-rails-loader - 0 stars, 0 forks.
keajs/kea-thunk - 0 stars, 3 forks.

0. Install kea-saga

We'll be using sagas in this example. To add support for them, install the kea-saga and redux-saga packages.

# if using yarn
yarn add kea-saga redux-saga

# if using npm
npm install kea-saga redux-saga --save

Then import sagaPlugin from kea-saga in your store.js and add it to the plugins array in getStore()

import sagaPlugin from 'kea-saga'

const store = getStore({
  plugins: [
    sagaPlugin
  ]
})

Read here for more

1. Input the username

Now that you have seen the end result, let's build it, piece by piece.

The first thing we want to do is to have an input element to enter the username.

If you have followed the other parts of this guide, you should know how to get this part working.

Hopefully after some tinkering you will come up with something like this:

import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { kea } from 'kea'

@kea({
  actions: () => ({
    setUsername: (username) => ({ username })
  }),

  reducers: ({ actions }) => ({
    username: ['keajs', PropTypes.string, {
      [actions.setUsername]: (_, payload) => payload.username
    }]
  })
})
export default class ExampleGithubScene extends Component {
  render () {
    const { username } = this.props
    const { setUsername } = this.actions

    return (
      <div className='example-github-scene'>
        <div style={{marginBottom: 20}}>
          <h1>Search for a github user</h1>
          <input value={username}
                 type='text'
                 onChange={e => setUsername(e.target.value)} />
        </div>
        <div>
          Repos will come here...
        </div>
      </div>
    )
  }
}

Live demo:

Search for a github user

Repos will come here...

You could probably have gotten as far with react's setState, but since we want to capture this event, we'll go straight for the kea solution.

2. Capture setUsername and trigger an API call

The next step is to intercept Redux and listen for each instance of the setUsername action being triggered.

We do this with the takeLatest option and a special worker function which does the API call.

The code to hook them up would look something like this:

@kea({
  actions: ...,
  reducers: ...,

  takeLatest: ({ actions, workers }) => ({
    [actions.setUsername]: workers.fetchRepositories
  }),

  workers: {
    * fetchRepositories (action) {
      const { username } = action.payload

      console.log(`setUsername called with ${username}`)
    }
  }
})

3. Trigger the actual call

Now that we have a time and a place where we can make the API call, let's actually make it.

We will use the standard Fetch API for it.

The code for this, with an additional 100ms debounce, will look like this:

import { delay } from 'redux-saga'

const API_URL = 'https://api.github.com'

@kea({
  // ...

  workers: {
    * fetchRepositories (action) {
      const { username } = action.payload

      yield delay(100) // debounce for 100ms

      const url = `${API_URL}/users/${username}/repos?per_page=250`
      const response = yield window.fetch(url)

      if (response.status === 200) {
        const json = yield response.json()
        // we have the repositories in `json`
        // what to do with them?
      } else {
        const json = yield response.json()
        // there's an error in `json.message`
        // what to do with it?
      }
    }
  }
})

Note the yield statements that we use to synchronously resolve promises without any nested callbacks!

There are more details on yield and the code that you can use inside the workers in the sliders guide.

4. Store the response of the call

Now that we get the repositories, where to put them?

The answer: in a few new reducers.

We're interested in 3 things:

  1. Whether we're currently fetching and data: isLoading
  2. The repositories that we have fetched: repositories
  3. Any error that might have occurred: error

We can get all of this by just adding two new actions:

  1. One to set the repositories: setRepositories
  2. One to set the error message: setFetchError

Hooking them up gives the following result:

@kea({
  actions: () => ({
    setUsername: (username) => ({ username }),
    setRepositories: (repositories) => ({ repositories }),
    setFetchError: (message) => ({ message })
  }),

  reducers: ({ actions }) => ({
    username: ['keajs', PropTypes.string, {
      [actions.setUsername]: (_, payload) => payload.username
    }],
    repositories: [[], PropTypes.array, {
      [actions.setUsername]: () => [],
      [actions.setRepositories]: (_, payload) => payload.repositories
    }],
    isLoading: [false, PropTypes.bool, {
      [actions.setUsername]: () => true,
      [actions.setRepositories]: () => false,
      [actions.setFetchError]: () => false
    }],
    error: [null, PropTypes.string, {
      [actions.setUsername]: () => null,
      [actions.setFetchError]: (_, payload) => payload.message
    }]
  })
})

Now we just need to call the right actions from the worker:

import { put } from 'redux-saga/effects' // new

@kea({
  workers: {
    * fetchRepositories (action) {
      const { setRepositories, setFetchError } = this.actions // new
      const { username } = action.payload

      yield delay(100)

      const url = `${API_URL}/users/${username}/repos?per_page=250`
      const response = yield window.fetch(url)

      if (response.status === 200) {
        const json = yield response.json()
        yield put(setRepositories(json)) // new
      } else {
        const json = yield response.json()
        yield put(setFetchError(json.message)) // new
      }
    }
  }
})

Note that we have to use the redux-saga put effect when dispatching the actions.

5. Display the result

The last step is to display the repositories to the user. To do this we use the following code:

export default class ExampleGithubScene extends Component {
  render () {
    const { username, isLoading, repositories, error } = this.props
    const { setUsername } = this.actions

    return (
      <div className='example-github-scene'>
        <div style={{marginBottom: 20}}>
          <h1>Search for a github user</h1>
          <input value={username}
                 type='text'
                 onChange={e => setUsername(e.target.value)} />
        </div>
        {isLoading ? (
          <div>
            Loading...
          </div>
        ) : repositories.length > 0 ? (
          <div>
            Found {repositories.length} repositories for user {username}!
            {repositories.map(repo => (
              <div key={repo.id}>
                <a href={repo.html_url} target='_blank'>{repo.full_name}</a>
                {' - '}
                {repo.stargazers_count} stars, {repo.forks} forks.
              </div>
            ))}
          </div>
        ) : (
          <div>
            {error ? `Error: ${error}` : 'No repositories found'}
          </div>
        )}
      </div>
    )
  }
}

Giving us the following result:

Search for a github user

No repositories found

It works! Almost...

What's still missing?

Well, for starters it would be nice if it would fetch all the respositories on page load.

Also, it would be great to sort the repositories by the number of stars

6. Last steps

No problem, we can fix that!

First, to load the repositories on page load, we have two options, both requiring the start function that is run when the component is mounted.

Option #1 is to call the fetchRepositories worker directly using redux-saga's call:

import { call } from 'redux-saga/effects'

@kea({
  start: function * () {
    const username = yield this.get('username')
    yield call(this.workers.fetchRepositories, { payload: { username } })
  }
})

This works, but it feels kind of hacky. We're pretending to be an action that triggers the worker.

The other option is to go through redux and execute the setUsername action with the default username:

@kea({
  start: function * () {
    const { setUsername } = this.actions
    const username = yield this.get('username')
    yield put(setUsername(username))
  }
})

It feels cleaner, but there's still something weird with calling setUsername with the username that's already set.

In the end, both approaches get the job done, and it's up to you to figure out which makes more sense depening on your situation.

The second problem had to do with sorting the results.

For that we can create a selector that takes the repositories as input and outputs a sorted array:

@kea({
  selectors: ({ selectors }) => ({
    sortedRepositories: [
      () => [selectors.repositories],
      (repositories) => {
        return repositories.sort((a, b) => b.stargazers_count - a.stargazers_count)
      },
      PropTypes.array
    ]
  })
})

Now all that's left to do is to replace repositories with sortedRepositories in your component.

Because the selectors are made with reselect under the hood, you can be sure that they will only be recalculated (resorted in this case) when the original input (repositories) change.

That's much better than re-sorting them on every call to render().

7. Final result

Adding the finishing touches gives us this final result:

Search for a github user

Found 11 repositories for user keajs!
keajs/kea - 1376 stars, 52 forks.
keajs/kea-website - 7 stars, 8 forks.
keajs/kea-example - 5 stars, 2 forks.
keajs/kea-on-rails - 5 stars, 0 forks.
keajs/kea-localstorage - 2 stars, 0 forks.
keajs/kea-saga - 2 stars, 3 forks.
keajs/kea-parallel - 2 stars, 0 forks.
keajs/kea-cli - 1 stars, 1 forks.
keajs/kea-parallel-loader - 0 stars, 0 forks.
keajs/kea-rails-loader - 0 stars, 0 forks.
keajs/kea-thunk - 0 stars, 3 forks.

With this code:

import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { kea } from 'kea'

import { put, call } from 'redux-saga/effects'
import { delay } from 'redux-saga'

const API_URL = 'https://api.github.com'

@kea({
  actions: () => ({
    setUsername: (username) => ({ username }),
    setRepositories: (repositories) => ({ repositories }),
    setFetchError: (message) => ({ message })
  }),

  reducers: ({ actions }) => ({
    username: ['keajs', PropTypes.string, {
      [actions.setUsername]: (_, payload) => payload.username
    }],
    repositories: [[], PropTypes.array, {
      [actions.setUsername]: () => [],
      [actions.setRepositories]: (_, payload) => payload.repositories
    }],
    isLoading: [true, PropTypes.bool, {
      [actions.setUsername]: () => true,
      [actions.setRepositories]: () => false,
      [actions.setFetchError]: () => false
    }],
    error: [null, PropTypes.string, {
      [actions.setUsername]: () => null,
      [actions.setFetchError]: (_, payload) => payload.message
    }]
  }),

  selectors: ({ selectors }) => ({
    sortedRepositories: [
      () => [selectors.repositories],
      (repositories) => repositories.sort(
                          (a, b) => b.stargazers_count - a.stargazers_count),
      PropTypes.array
    ]
  }),

  start: function * () {
    const { setUsername } = this.actions
    const username = yield this.get('username')
    yield put(setUsername(username))
  },

  takeLatest: ({ actions, workers }) => ({
    [actions.setUsername]: workers.fetchRepositories
  }),

  workers: {
    * fetchRepositories (action) {
      const { setRepositories, setFetchError } = this.actions
      const { username } = action.payload

      yield delay(100) // debounce for 100ms

      const url = `${API_URL}/users/${username}/repos?per_page=250`
      const response = yield window.fetch(url)

      if (response.status === 200) {
        const json = yield response.json()
        yield put(setRepositories(json))
      } else {
        const json = yield response.json()
        yield put(setFetchError(json.message))
      }
    }
  }
})
export default class ExampleGithubScene extends Component {
  render () {
    const { username, isLoading, repositories,
            sortedRepositories, error } = this.props
    const { setUsername } = this.actions

    return (
      <div className='example-github-scene'>
        <div style={{marginBottom: 20}}>
          <h1>Search for a github user</h1>
          <input value={username}
                 type='text'
                 onChange={e => setUsername(e.target.value)} />
        </div>
        {isLoading ? (
          <div>
            Loading...
          </div>
        ) : repositories.length > 0 ? (
          <div>
            Found {repositories.length} repositories for user {username}!
            {sortedRepositories.map(repo => (
              <div key={repo.id}>
                <a href={repo.html_url} target='_blank'>{repo.full_name}</a>
                {' - '}{repo.stargazers_count} stars, {repo.forks} forks.
              </div>
            ))}
          </div>
        ) : (
          <div>
            {error ? `Error: ${error}` : 'No repositories found'}
          </div>
        )}
      </div>
    )
  }
}

There's still one thing that's broken:

If a github user or organisation has more than 100 repositories, only the first 100 results will be returned. Github's API provides a way to ask for the next 100 results (the Link headers), but as resolving this is outside the scope of this guide, it will be left as an exercise for the reader ;).

Next page: Connected logic