withDerivedProps is a Higher Order Component that allows to derive new data from existing props in a succint manner and memoize them automatically. If it reminds you of withPropsOnChange
HOC from recompose
, you are right - we based this HOC on it. We adjusted the API for most common usecases to be easier and safer to use. We have also written guidelines that tell you when and why to use it.
npm i @surglogs/with-derived-props recompose
Since this library is basically an alternative to
reselect
library, our examples are based on the reselect examples, so that you can compare them visually.
Let's say we have a list of todos stored in the redux
store and now we need to filter the todos by their state (complete or active) and show them to the user.
Our first try might look something like this: (to see the whole code please look here)
import { connect } from 'react-redux'
import { compose } from 'recompose'
import { toggleTodo } from '../actions'
import TodoList from '../components/TodoList'
const getVisibleTodos = (todos, filter) => {
switch (filter) {
case 'SHOW_ALL':
return todos
case 'SHOW_COMPLETED':
return todos.filter(t => t.completed)
case 'SHOW_ACTIVE':
return todos.filter(t => !t.completed)
}
}
const mapStateToProps = state => {
return {
todos: getVisibleTodos(state.todos, state.visibilityFilter),
}
}
const VisibleTodoList = compose(
connect(
mapStateToProps,
{ toggleTodo },
),
)(TodoList)
export default VisibleTodoList
Looks good! We got the todos
and visibilityFilter
from the redux store and computed filtered todos using getVisibleTodos
function. So what is the problem then?
Unfortunately, it is not very efficient and might slow down your app significantly. The problem is that mapStateToProps
is called every time redux state changes (even if nothing related to your component really changed). Therefore you cannot do anything complex in your mapStateToProps
. In our example, we compute the visible todos everytime the state changes. If we have a lot of todos (which is absolutely possible in real app) our app might become very slow because of this.
Another problem is that every time we compute getVisibleTodos
, we create a new array and therefore also a new reference. Because of that, we cannot effectively use optimizations pure or memo which are the basic techniques to eliminate wasted renders and speed up your app.
Looks bad ๐ข. What can we do with it?
If you use redux, you probably already heard about (or even better used) this library. It allows you to memoize our getVisibleTodos
which removes the complexity from mapStateToProps
and also removes the problem with creating new references. The memoized functions are called selectors.
You can take a look at reselect's solution to our problem.
Well, why shouldn't you use reselect right?
It has serious problems. If you try to reuse the same selector in two different components, the memoization stops to work correctly and the optimizations are gone. This is very unpleasant since this problem is very hard to debug. Reselect provides a solution how to prevent this, however it requires tons of boilerplate... which is totally unnecessary because we have a better solution ;).
First, we get the stuff we need to compute the filtered todos:
const mapStateToProps = state => {
return {
todos: state.todos,
visibilityFilter: state.visibilityFilter,
}
}
Pretty trivial.
Notice we are not creating here any new data here, we merely pull existing data from the store.
Next we compute the filtered data in withDerivedProps
HOC. As first argument we provide keys of props we need to compute the data ['todos', 'visibilityFilter']
. Next we provide an object with properties we want to compute and pass the function using which the data is computed { filteredTodos: ({ todos, visibilityFilter }) => { /* compute filtered todos */ } }
.
import withDerivedProps from 'withDerivedProps'
withDerivedProps(['todos', 'visibilityFilter'], {
filteredTodos: ({ todos, visibilityFilter }) => {
switch (filter) {
case 'SHOW_ALL':
return todos
case 'SHOW_COMPLETED':
return todos.filter(t => t.completed)
case 'SHOW_ACTIVE':
return todos.filter(t => !t.completed)
}
},
})
Generally, the withDerivedProps
structure looks like this:
withDerivedProps(keys, {
[computedProp]: computationFunction,
})
Finally we compose the connect
and withDerivedProps
HOCs using compose
function from recompose
:
import { connect } from 'react-redux'
import { compose } from 'recompose'
import withDerivedProps from 'withDerivedProps'
import { toggleTodo } from '../actions'
import TodoList from '../components/TodoList'
const mapStateToProps = state => {
return {
todos: state.todos,
visibilityFilter: state.visibilityFilter,
}
}
const VisibleTodoList = compose(
connect(
mapStateToProps,
{ toggleTodo },
),
withDerivedProps(['todos', 'visibilityFilter'], {
filteredTodos: ({ todos, visibilityFilter }) => {
switch (filter) {
case 'SHOW_ALL':
return todos
case 'SHOW_COMPLETED':
return todos.filter(t => t.completed)
case 'SHOW_ACTIVE':
return todos.filter(t => !t.completed)
}
},
}),
)(TodoList)
export default VisibleTodoList
Tada ๐ we are done. How does it work? Every time new props come to withDerivedProps
, the HOC checks if any relevant prop (todos
or visibilityFilter
) changed. If it didn't, the HOC does nothing. If something relevant did change, the HOC recomputes the filteredTodos
using the function we provided.
If we want to make the code more readable we can extract the computation function somewhere else. It is a good practise to prefix the function name with derive
but it is really up to you.
const deriveFilteredTodos = ({ todos, visibilityFilter }) => {
switch (filter) {
case 'SHOW_ALL':
return todos
case 'SHOW_COMPLETED':
return todos.filter(t => t.completed)
case 'SHOW_ACTIVE':
return todos.filter(t => !t.completed)
}
}
withDerivedProps(['todos', 'visibilityFilter'], {
filteredTodos: deriveFilteredTodos,
})
Now you can notice the key difference between reselect
and withDerivedProps
approach. withDerivedProps
does the memoization in the component whereas reselect
binds it to the computation function. That's why withDerivedProps
has no problem with reusing the same computation function in multiple components as reselect
does.
Note that your computation functions receive only the props that you listed (['todos', 'visibilityFilter']
). That's a good thing, because if you received all the props in the computation function, you might forget to list it in the keys and the data might not recompute when it should. If you want to receive all the props, you can provide third optional argument { passAllProps: true }
. However, DON'T DO THIS IF YOU ARE NOT SURE WHAT YOU ARE DOING! ;).
If you want to derive several props from the props, you can either use multiple withDerivedProps
usages in row:
compose(
connect(...),
withDerivedProps(...),
withDerivedProps(...),
withDerivedProps(...)
)
or you can put them in single withDeriveProps
:
withDerivedProps(['todos', 'visibilityFilter'], {
filteredTodos: ({ todos, visibilityFilter }) => {
switch (filter) {
case 'SHOW_ALL':
return todos
case 'SHOW_COMPLETED':
return todos.filter(t => t.completed)
case 'SHOW_ACTIVE':
return todos.filter(t => !t.completed)
}
},
shouldShowEmptyLabel: ({ filteredTodos }) => filteredTodos.length === 0
})
Please note that the first approach is more flexible and might optimize the component better, especially if the props are computed from many different props.
For derivations that depend only on one prop, you can you use simpler version of our HOC withDerivedProp
:
import { withDerivedProp } from 'withDerivedProps'
withDerivedProp('todos', todos => todos.filter(t => !t.completed), { target: 'visibleTodos' })
If you don't want to create a prop with name but want to overwrite the existing one, you can omit the third argument:
withDerivedProp('todos', todos => todos.filter(t => !t.completed))
What if I want use the computation function outside the component (e.g. while calling API)? I guess it will be slow because the function itself is not memoized!
Generally, no it won't be slow, you don't have to worry. It is because during the API call (or similar action) you only need to compute the data once unlike in component, where it can happen dozens times per second.
However, even in the component itself it wouldn't be as bad to recompute the data often. The main problem is that recomputation creates new references which cause rerenders. And rerenders (or React reconciliation) can be very very time expensive (especially in long lists). That's the main reason, why we need to get rid of redundant data recomputations.