Skip to content

Latest commit

 

History

History
238 lines (175 loc) · 9.95 KB

README.md

File metadata and controls

238 lines (175 loc) · 9.95 KB

withDerivedProps

Build Status

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.

Instalation

npm i @surglogs/with-derived-props recompose

What is this library good for?

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.

Todo list example

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?

The problem

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?

Reselect

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 ;).

withDerivedProps to the rescue

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! ;).

Deriving multiple props at once

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.

withDerivedProp

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))

Frequently asked questions

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.