Our Thursday thoughts are learning about React state management with Lenses from Web Developer at Football Radar Max Willmott. Max looks at Lenses and how they use them in their UIs at Football Radar. Happy learning!
We're going to take a look at Lenses and how we use them in our UIs at Football Radar to keep our state immutable and improve how we manage it.
Primers
I'm going to assume knowledge of React & Redux/Flux.
Immutability in React is important for controlling the renders of your components, often via implementing a 'PureComponent'. If you’re not familiar with this construct I recommend reading the documentation on before reading on; https://facebook.github.io/react/docs/react-api.html#react.purecomponent.
Lenses are used for “focusing” in on a particular piece of a complex data whilst remaining in the context of that data. An acronym with types:
function setSomeDeepProperty :: (data: T, lensToSomeDeepProp: Lens) => T;
function updateSomeOtherDeepProperty :: (data: T, lensToSomeDeepProp: Lens) => T;
For more detail, I recommend the excellent series "Thinking in Ramda";http://randycoulman.com/blog/2016/07/12/thinking-in-ramda-lenses/.
A more in-depth explanation of lenses can be found here; https://medium.com/@dtipson/functional-lenses-d1aba9e52254
Approach
- Introduce our state and the 'RequestLifecycle' object.
- Write some utility functions for 'RequestLifecycle' using Lenses.
- Use these Lenses in our reducer.
- "Computed properties" after our reducer.
- Disadvantages (lazy data structure, learning curve).
- Conclusion.
Introducing 'State' & 'RequestLifecycle'
type State = {
requests: {
//GET /games
getGames: RequestLifeCycle<Game[]>;
//POST /watcher {gameId, watcherId}
postWatcher: RequestLifeCycle<Watcher>;
},
games: Game[];
views: {
myGames: Game[]
}
}
interface Game {
id: number;
homeTeam: Team;
awayTeam: Team;
watchers: Watcher[];
}
interface Watcher {
watchedAt: Date;
...others, probably
}
A 'Watcher' represents the instance of a user watching the game. In reality, they are watching this game to record all the data necessary for our analysis. Multiple users can watch a single game if the game is recorded the 'watchedAt' date may differ between each one of them.
We have the following scenario: when the 'postWatcher' request succeeds we want to add that watcher to the 'game.watchers' array. We must do this whilst treating our 'State' as an immutable object.
interface RequestLifeCycle<T> {
request: SomeXHRObj;
status: "NOT_ASKED" | "LOADING" | "SUCCESS" | "FAILURE";
error?: Error;
data?: T;
}
Introduction to 'RequestLifeCycle'
'RequestLifeCycle' is a pattern we use often here at Football Radar. Its job is to describe the lifecycle of a HTTP request in our state. This is proved very useful for many little situations such as not allowing a modal to close whilst a related request is in flight, preventing double submits, knowing when to display network notifications, the list goes on. This is heavily inspired by https://medium.com/elm-shorts/how-to-make-impossible-states-impossible-c12a07e907b5
Making 'RequestLifecycle' useful
In the Flux world we're going to be dispatching actions when our source request does something. We want these actions to result in our reducer returning a new 'State' with an updated request such that (for example) 'state.requests.getGames !== prevState.requests.getGames'.
const statusL = R.lensProp("status");
const dataL = R.lensProp("data");
const errorL = R.lensProp("error");
We define a lens for each property we will want to update in some way (we can and will use deeper paths most of the time). You'll notice we use the convention 'propL' for the variable names, this is similar to jQuery's '$element' or Rx's 'stream$'.
const setStatus = R.set(statusL);
const setData = R.set(dataL);
const setError = R.set(errorL);
const resetError = setError(null);
Next we take advantage of 'Ramda'’s currying on 'R.set', a function which takes a lens and sets the given value the property which the lens points to. This function will return a new copy of the object with the new value assign to the property. This is key for our immutability and is our primary driver for using lens.
export const setToLoading = R.compose(
setStatus("LOADING"),
resetError
);
export const setToSuccess = (payload, requestLifecycle) => R.compose(
setStatus("SUCCESS"),
setData(payload),
resetError
)(requestLifecycle)
export const setToFailed = (error, requestLifecycle) => R.compose(
setStatus("FAILED"),
setData(null),
setError(error)
)(requestLifecycle)
Lastly we have our 3 public functions. Whilst this isn't a very complex example, we can see how lenses aid composition of functions over objects. They let us go as deep as we need to into the object and then come all the way back again.
Composition types: A ---------------> A ---------------> A
Functions: change A.1 change A.2.a change A.3
You'll notice that all three of these functions could be achieved by with a simple 'Object.assign'. In this example, there is a strong argument for that, but once the data shape gets deeper this approach really starts to shine.
Usage in our reducer
Now we have our pure function updates for 'RequestLifecycle' we can use them in our reducer:
//reducer.js
const postWatcherL = R.lensPath(["requests", "postWatcher"]);
function reducer(state, action) {
...
case "POST_WATCHER_START":
return R.over(postWatcherL, RequestLifecycle.setToLoading, state);
case "POST_WATCHER_FAILURE":
return R.over(postWatcherL, RequestLifecycle.setToFailed(action.payload), state);
case "POST_WATCHER_SUCCESS":
return R.compose(
R.over(postWatcherL, RequestLifecycle.setToSuccess(action.payload)),
...//todo add the watcher to the game.watchers array
)(state);
...
}
Let's look at a few individual LOC:
// :: (state: State) => RequestLifeCycle<Watcher>
const postWatcherL = R.lensPath(["requests", "postWatcher"]);
Same as our previous lens but we're looking deeper into the state object to reach our 'RequestLifecycle'.
//R.over :: (lens: R.Lens<S, T>, updateT: (T => T), state: S) => S
return R.over(postWatcherL, RequestLifecycle.setToFailed(action.payload), state);
This line here, at least for me, really illustrates the power of lenses. 'R.over' is a great function: it takes a lens and an update function. This function will be passed the current value of the lens over the state and returns the new value. This change is then propagated upwards to ensure each level is a new object:
state //shallow copy, new .requests (R.over)
.requests //shallow copy, new .postWatcher (R.over)
.postWatcher //shallow copy, new .status & .data (setToSuccess)
.status //new value
.data //new value
state !== prevState
state.requests !== prevState.requests
state.requests.postWatcher !== prevState.requests.postWatcher
state.requests.getGames === prevState.requests.getGames
state.games === prevState.games
Our state is perfectly updated, with the minimal number of changes so our React renders can optimise. Another thing to note here is how this update function is completely independent of the shape of our state. This is another win in projects which you must maintain as it's less to learn when you inevitably come back to it.
Note, due to 'Ramda' creating shallow copies we're actually achieving a basic level of structural sharing.
Handling the postWatcher response
We have a 'Game[]' in our state which we'll assume has been populated already. When the 'postWatcher' request has succeeded we need to add the watcher to the array in the game, such that 'state.games[x].watchers[y]' has a new reference at each level. The main difference in this example is that we're dealing with arrays ( 'game.watchers' and 'state.games').
Let's extend the action handler above (I've extended 'action.payload' for this example too):
case "POST_WATCHER_SUCCESS":
return R.compose(
R.over(postWatcherL, RequestLifecycle.setToSuccess(action.payload)),
R.over(R.lensPath(["games", action.payload.index, "watchers"]), R.append(action.payload.watcher))
)(state);
We're creating a lens at the point we're using it depends on a dynamic index:
// :: (state: State) => Watcher[]
R.lensPath(["games", action.payload.index, "watchers"])
Once again we take advantage of 'Ramda''s currying. This function now only takes an array and will add our watcher to it:
// :: (watchers: Watcher[]) => Watcher[]
const appendWatcher = R.append(action.payload.watcher)
As before, combining the two with 'R.over' adds our watcher to our game whilst updating the full path in the state:
state //shallow copy, new games prop (R.over)
games //new array (R.over)
targetGame(n) //shallow copy (R.over)
watchers //new array (R.over)
newWatcher //new object (action.payload)
...originals //same watcher objects
...originalGames //same game objects
state !== prevState
state.requests !== prevState.requests
state.games !== prevState.games
state.games[n] !== prevState.games[n]
state.games[n].homeTeam|awayTeam === prevState.games[n].homeTeam|awayTeam
state.games[n].watchers !== prevState.games[n].watchers
Going a bit further with post-reducer updates
'state.views.myGames' is a subset of 'state.games'. We can compute 'state.views.myGames' only when 'state.games' has changed with a nice function taking advantage of lenses:
type PostReduceUpdate = {
lenses: R.Lens[]
fn: (state: State, previousState: State) => State
};
const updateMyGames: PostReduceUpdate = {
lenses: [gamesL],
fn: (state) => R.set(myGamesL, state.games.filter(isCurrentUser), state)
}
We're going to loop over 'updateMyGames.lenses' and 'R.view' them for both the 'oldState' and the 'nextState', straight after our reducer has run. If the results are not referentially equivalent then 'updateMyGames.fn' will be run.
function haveLensesChanged(nextState: State, previousState: State, lenses: R.Lens[]): boolean {
//foreach lens, pair up the next and previous values
const stateToCheck = lenses.map(lens => [R.view(lens, nextState), R.view(lens, previousState)]);
//foreach (next,prev) pair, equality compare them to see if they've changed
const statesAreEqual = stateToCheck.map(([next, prev]) => next === prev);
//conclude if any of our pairs have changed
return R.any(R.equals(false), statesAreEqual);
}
function runPostReduceUpdates(postReduceUpdates: PostReduceUpdate[], oldState: State, nextState: State): State {
return postReduceUpdates
//filter out updates which do not need to run
.filter({lenses}) => haveLensesChanged(nextState, oldState, lenses) === true)
//run each update
.reduce((state, {fn}) => fn(state), nextState);
}
//At the end of our reducer
nextState = runPostReduceUpdates([updateMyGames], state, nextState);
So now we have a function which can check the data in any location in our 'State' for changes and only run another function if the data at that location has changed. Lenses allow us to write this running function without any knowledge of the shape of 'State' and defers that to the implementation of each 'PostReduceUpdate'.
Disadvantages of lenses
You may have thought to yourself in a few of these examples "Well I could have easily done that with 'Object.assign' or '.slice(0)'". Hopefully, the other examples demonstrate when that approach doesn't make things easier but it does highlight the possibility of potentially over solving the problem.
Another issue which we glossed over is the shape of 'State' itself. Lenses let us abstract away the shape of the data but if we're not disciplined we can end up with poorly shaped data because "don't worry, we'll just use a lens to get there". Shallow copying objects over and over again isn't free either so if you can mould the data you should definitely consider that first.
Lenses are also tricky to type in TypeScript (I can't speak for Flow). You're relying on strings for property names when creating them, something which TypeScript cannot (yet) help you with.
Lastly there is a learning curve. I look back at functions I wrote before using lenses and could see how I would do them again if that project had lenses (I'm not saying lenses always superior). This thought applies backwards; how do people who haven't used lenses understand your function which is heavily influenced by them? This is the same with any new piece of technology and requires careful rollout and knowledge sharing.
In this post we've only used lenses in our flux style reducer and that is also the case in our code bases in Football Radar. Once we're out of the reducer and in the 'React' world we just read the state directly; 'this.state.views.myGames' etc. This limitation and consistency has helped with the learning curve.
Conclusion
- We introduced our N-levels deep 'State'.
- Acknowledged the need to treat our 'State' as an immutable object (though we never technically made it immutable!).
- Shown how we can achieve this with lenses and functions such as 'R.over' for updating deep in 'State'
- Taken this a step further to inspect our 'State' for relevant changes.
Additional resources
- Egghead.io have a great video on using lenses with 'React''s 'setState'; https://egghead.io/lessons/react-update-component-state-in-react-with-ramda-lenses
- The "Thinking in Ramda" series has an introduction to lenses; http://randycoulman.com/blog/2016/07/12/thinking-in-ramda-lenses/
- In the same series there's a nice post on immutability and arrays; http://randycoulman.com/blog/2016/07/05/thinking-in-ramda-immutability-and-arrays/.
- Further explanation of lenses in JavaScript; https://medium.com/@dtipson/functional-lenses-d1aba9e52254.
- School of Haskell on lenses; https://www.schoolofhaskell.com/user/tel/lenses-from-scratch.
- Pure components and the need for immutability; https://facebook.github.io/react/docs/react-api.html#react.purecomponent.