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
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
]
})
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
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:
- Whether we're currently fetching and data:
isLoading
- The repositories that we have fetched:
repositories
- Any error that might have occurred:
error
We can get all of this by just adding two new actions:
- One to set the repositories:
setRepositories
- 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
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
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 ;).