Introduction To Functional Front-Ends With Inferno— Side Effects And Routing

A. Sharif
JavaScript Inside
Published in
8 min readJan 2, 2017

Introduction

In part one we covered the basics for building a functional front-end with Inferno, in part two we will focus on some more advanced topics like routing and fetching data from a server. We should have a basic understanding of how to setup Inferno by now and how to render something useful to the DOM.

From here on out, we will build an example from scratch, so part one isn’t a prerequisite, but is definitely useful for understanding the topic. If you haven’t installed create-inferno-app yet, here’s what we will need to do:

npm install create-inferno-appcreate-inferno-app advanced_examplecd advanced_example/

We will also install a couple of libraries, that we will need later on.

npm install --save ramdanpm install --save historynpm install --save myro

myro is a bidirectional router, that enables us to easily create and retrieve routes and history is library that wraps the native history object and abstracts away any inconsistencies between various environments. Now that we have all the needed libraries installed we are ready to build a minimal App. To demonstrate data fetching and routing and to keep things reasonable, we will build an App that loads users and enable to edit them. Nothing too special, but it enables us to cover the most important issues.

Running npm start should start the application at http://localhost:3000/.

Effects

To begin with, let’s adapt the index.js file. In the previous part, we defined a startApp, a main and a dispatch function.

const startApp = (state, update, view) => {

const dispatch = R.curry((state, action) =>
() => main(update(action, state), view))
const main = (state, view) => render(view(dispatch(state), state))

main(state, view)
}

These function are still useful, but we will need to introduce a number of small changes. First of all, we’ll extend startApp to receive the render function as an argument. Secondly, we will need to handle side effects. In our current implementation we only receive the current state and an action and update the state via calling update with the current state and the action. But how can we fetch data or run other asynchronous actions in our current implementation, you might be asking yourself?

const main = ([model, effects], view) => {
render(view(signal(model), model))
runEffects(signal(model), effects)
}

The previously passed in state now, consists 0f a tuple containing model and effects. runEffects (see elmar.js for further information) expects a signal and the effects and then maps over the effects and finally calls signal with the result of that effect.

const runEffects = (signal, effects) =>
R.forEach(e => e().then(a => signal(a)()), effects)

You might also have noticed that in our updated implementation we run then after calling every function. This depends on the fact that we’re dealing with promises. runEffects can also be rewritten to work with Tasks (as we will see in part three).

Here’s the current implementation.

import Inferno from 'inferno';
import R from 'ramda'
const renderer = R.curry((node, component) => Inferno.render(component, node))const render = renderer(document.getElementById('app'))const runEffects = (signal, effects) =>
R.forEach(e => e().then(a => signal(a)()), effects)
const StartApp = (render, [state, effects], view, update) => {

const signal = R.curry((state, action) =>
() => main(update(action, state), view))

const main = ([state, effects], view) => {
render(view(signal(state), state))
runEffects(signal(state), effects)
}

main([state, effects], view)
}

Now that we have our main function ready, we can start to think about how we want to initially fetch the users and render them to the screen.

Let’s define a directory and name it Users. Inside the newly created directory we will add an index.js file. Let’s also assume we have an endpoint setup for fetching users. The init function needs to take care of loading the initial users we want to have displayed.

import Inferno from 'inferno'
import
R from 'ramda'
import
{getUsers} from '../api'
export const loadUsers = () => {
return getUsers()
.catch(err => null)
.then(Action.OnLoadUsers)
}
const Action = {
OnLoadUsers: users => model => [{
...model,
users: users || model.users,
loading: false,
error: !users || !users.length,
}, []],
}
const init = model =>
[{...model, error: false, loading: true}, [loadUsers]]

loadUsers in this specific case calls the OnLoadUsers action after the fetch operation has been run. We could also handle any errors when needed, but for a basic understanding the example should suffice. In case you’re wondering about this UI anti-pattern, we’ll fix this in the final installment of the series.

Finally the update and view function are similar to our previous examples in part one.

export default {init, update, view}

const update = (action, model) => (action(model))
const view = (signal, {error, loading, users}) => {
return (
<div>
{error ? <span>Error. Not Found.</span> :
loading ?
<p>Loading.</p> :
<div>{R.map(({id, firstName, lastName}) =>
<div key={id}>{firstName} {lastName}</div>
, users)}
</div>
}
</div>
)
}
export default {init, update, view}

All that is left to do is to call our previously defined startApp function with the needed arguments.

StartApp(render, init({users: []}), view, update)

Calling startApp will render a ‘loading’ text to the screen and finally display a list of user first and last names when the loading has been successful.

You can checkout the example here.

By choosing this approach, we’re able to load data asynchronously, though only loading initial data is not enough for a regular App. We would want the ability to click on any user in the list and update the user information f.e. To achieve this we need to figure out how to route to a detail or a list view and also take care of updating the user data. This gives us enough context to extend our App.

Routing

We need to define a number of routes like home, users list or user detail. myro enables us to define the needed routes by passing a JSON object containing route name and props. props will simply contain a string representation of the route name. We can actually define what ever we like in props, even return a function or a component. To keep everything decoupled we will stick with defining route constants and have the view figure out which component to display.

import myro from 'myro'const HOME_ROUTE = 'HOME_ROUTE'
const USERS_LIST_ROUTE
= 'USERS_LIST_ROUTE'
const USERS_EDIT_ROUTE
= 'USERS_EDIT_ROUTE'
const route = myro({
'/users': {
name: 'users',
props: USERS_LIST_ROUTE,
routes: {
'/:id': {
name: 'detail',
props: USERS_EDIT_ROUTE,
}
}
},
'': {
name: 'home',
props: HOME_ROUTE
},
})
export default route
export
{
HOME_ROUTE,
USERS_EDIT_ROUTE,
USERS_LIST_ROUTE,
}

Once we have our routes in place, all that is left to do is use history for retrieving and updating the location. We will use the history library and expose a Navigation object containing a method for gaining access to the current route as well as a method for updating the location.

import createHistory from 'history/createBrowserHistory'const history = createHistory()const Navigation = {
getLocation: () => history.location.pathname,
changeLocation: (action, url) => () => {
history.push(url)
return {
then: cb => cb(action(url)),
}
}
}
export default Navigation

This is all that is needed for creating a basic routing. We can update our startApp function by adding a route property to our model.

StartAppAdvanced(render,
init({users: [], route: routes(Navigation.getLocation())}),
view,
update)

We call our previously defined routes with the current location and get an object containing the matched route. For example if our current location is http://localhost:3000/ we would get this representation in return.

{ params: {}, props: "HOME_ROUTE", parent: null, name: "home", ... }

From here on, our model has a route property containing the information we can later access to decide which component to render to the screen.

Detail View

Our next step is to separate the views into their own files. We can move our list view into it’s own Users/List.js file and correspondingly define Users/Detail.js which only consists of a view for displaying the specific user’s information.

import Inferno from 'inferno'
import
R from 'ramda'
import
Action from './Action'
export default (signal, {error, user}) =>
<div>
<h3>User Detail</h3>
<div>
{error ? <span>Error. Not Found.</span> :
user ?
<div key={user.id}>
{user.firstName} {user.lastName}
<button onClick={signal(Action.UpdateUser(user))}>
Save
</button>
<button onClick={signal(Action.ShowUsers)}>List</button>
</div> :
<p>Loading.</p>}
</div>
</div>

You might have noticed that we’re importing Action. While in our previous examples we always defined the Actions together with the update, init and view functions, we will need to move our action definitions into their own file as well now. By doing so, we can share them between the list and detail view. Our Users/Action.js:

import R from 'ramda'
import
Navigation from '../Navigation'
import
routes from '../routes'
import
{editUser, getUsers} from '../api.js'

export const
loadUsers = () => {
return getUsers()
.catch(err => null)
.then(Action.OnLoadUsers)
}

const updateUser = user => () => {
return editUser(user)
.catch(err => null)
.then(Action.OnUpdateUser)
}

const Action = {
LoadUsers: model => [{...model, error: false}, [loadUsers]],
UpdateUser: user => model => [{...model, error: false}, [updateUser({...user, firstName: R.reverse(user.firstName)})]],
OnLoadUsers: users => model => [{
...model,
users: users || model.users,
error: !users || !users.length,
}, []],
OnUpdateUser: user => model => {
const getUsers = R.prop('users', model)
const idx =
R.findIndex(R.propEq('id', R.prop('id', user)), getUsers)
const users = R.update(idx, user, getUsers)
return [{
...model,
users: users,
error: !user,
}, [ Navigation.changeLocation(
Action.OnLocationChange, routes.users())]]
},
ShowUser: id => model => [
model,
[Navigation.changeLocation(
Action.OnLocationChange, routes.users.detail({id}))]
],
ShowUsers: model => [
model,
[Navigation.changeLocation(
Action.OnLocationChange, routes.users())]
],
OnLocationChange: location => model => [
{...model, route: routes(location)},
[]
],
}

export default Action

There’s a lot going on inside Action.js. We added UpdateUser and OnUpdateUser functions. The first for calling the updateUser API and the other when the server returns a result. The other interesting actions are route specific. We have a ShowUser as well as a ShowUsers action for either dispatching to a detail or a list view. Another interesting fact is that we’re not using any strings to define routes.

ShowUser: id => model => [
model,
[Navigation.changeLocation(
Action.OnLocationChange, routes.users.detail({id}))]
],

For example in ShowUser we’re defining a route by simply calling routes.users.detail({id}) which returns the correct path /users/1 (in case id is 1). As previously mentioned myro is only concerned with route matching and generating URLs. Check this introduction for a better understanding.

At this point, we have covered the most important aspects and all that is still missing is our view in Users/index.js.

import Inferno from 'inferno'
import
R from 'ramda'
import
List from './List'
import
Detail from './Detail'
import Action
, {loadUsers} from './Action'
import
{
HOME_ROUTE,
USERS_EDIT_ROUTE,
USERS_LIST_ROUTE,
} from '../routes'

const
init = model => [{...model, error: false}, [loadUsers]]
const update = (action, model) => (action(model))
const NotFound = () => <div>Not Found.</div>
const Home = () => <div>Home</div>

const getUser = (id, users) =>
({user: R.find(R.propEq('id', parseInt(id)), users)})
const isRoute = (model, route) =>
R.equals(R.path(['route', 'props'], model), route)

const view = (signal, model) => {
if (!R.props('route', model)) return NotFound()
return (
<div>
{isRoute(model, USERS_EDIT_ROUTE) &&
Detail(signal, getUser(R.path(['route', 'params', 'id'], model), R.prop('users', model)))}
{isRoute(model, USERS_LIST_ROUTE) && List(signal, model)}
</div>
)
}

export default {init, update, view}

We let the view decide which component to render, based on the passed in router property. Home and NotFound should actually be in a parent view, but to keep the example simple I defined them inside the Users view. So in a real world case, you might have a main component that checks if any routes have been defined and either displays a home component or passes the route definition down the hierarchy, where the corresponding view would define which component to render.

Checkout the complete example here.

We have covered routing and dealing with side effect, what’s left is the optimization part. There is room for improvement and we will see what we can gain by leveraging Inferno and an Elm inspired architecture in the third and final part.

If you have any feedback or questions please leave a comment here or on Twitter.

--

--

A. Sharif
JavaScript Inside

Focusing on quality. Software Development. Product Management.