Skip to content

Latest commit

 

History

History
245 lines (196 loc) · 10.1 KB

recompose.md

File metadata and controls

245 lines (196 loc) · 10.1 KB
title
Recompose patterns

Recompose is a toolbelt for working with React components in a reusable, functional way. The workflow is similar to libraries like Underscore or Lodash, which can help you avoid re-implementing common patterns and keep your code DRY. Check out the Recompose API for all the details.

Loading status

A common use-case when using the graphql HOC is to display a "loading" screen while your data is being fetched. We often end up with something like this:

const Component = props => {
  if (props.data.loading) {
    return <LoadingPlaceholder>
  }

  return (
    <div>Our component</div>
  )
}

Recompose has a utility function branch() which lets us compose different HOCs based on the results of a test function. We can combine it with another Recompose method, renderComponent(). So we can say "If we are loading, render LoadingPlaceholder instead of our default component", like so:

import { propType } from 'graphql-anywhere'

const renderWhileLoading = (component, propName = 'data') =>
  branch(
    props => props[propName] && props[propName].loading,
    renderComponent(component),
  );

const Component = props => (<div>Our component for {props.user.name}</div>)
Component.propTypes = {
  // autogenerated proptypes should be in place (if no error)
  user: propType(getUser).isRequired,
}

const enhancedComponent = compose(
  graphql(getUser, { name: "user" }),
  renderWhileLoading(LoadingPlaceholder, "user")
)(Component);

export default enhancedComponent;

This way, our wrapped component is only rendered outside of the loading state. That means we only need to take care of 2 states: error or successful load.

Note: loading is only true during the first fetch for a particular query. But if you enable options.notifyOnNetworkStatusChange you can keep track of other loading status using the data.networkStatus field. You can use a similar pattern to the above.

Error handling

Similar to the loading state above, we might want to display a different component in the case of an error, or let the user refetch(). We will use withProps() to include the refetch method directly in the props. This way our universal error handler can always expect it to be there and is more decoupled.

const renderForError = (component, propName = "data") =>
  branch(
    props => props[propName] && props[propName].error,
    renderComponent(component),
  );

const ErrorComponent = props =>(
  <span>
    Something went wrong, you can try to
    <button onClick={props.refetch}>refetch</button>
  </span>
)

const setRefetchProp = (propName = "data") =>
  withProps(props => ({refetch: props[propName] && props[propName].data}))

const enhancedComponent = compose(
  graphql(getUser, { name: "user" }),
  renderWhileLoading(LoadingPlaceholder, "user"),
  setRefetchProp("user"),
  renderForError(ErrorComponent, "user"),
)(Component);

export default enhancedComponent;

Now we can count on results being available for our default component and don't have to manually check for loading state or errors inside the render function.

Query lifecycle

There are some use-cases when we need to execute code after a query finishes fetching. From the example above, we would render our default component only when there is no error and loading is finished.

But it is just a stateless component; it has no lifecycle hooks. If we need extra lifecycle functionality, Recompose's lifecycle() comes to the rescue:

const execAtMount = lifecycle({
  componentWillMount() {
    executeSomething();
  },
})

const enhancedComponent = compose(
  graphql(getUser, { name: "user" }),
  renderWhileLoading(LoadingPlaceholder, "user"),
  setRefetchProp("user"),
  renderForError(ErrorComponent, "user"),
  execAtMount,
)(Component);

The above works well if we just want something to happen at component mount time.

Let's define another more advanced use-case, for example, using react-select to let a user pick an option from the results of a query. I want to always display the react-select, which has its own loading state indicator. Then, I want to automatically select the predefined option after the query finishes fetching.

There is one caveat: we need to be aware that the query can skip the loading state when data is already in the cache. That would mean we need to handle networkStatus === 7 on mount.

We will also use recompose's withState() to keep value for our option picker. For this example we will assume the default data prop name is unchanged.

const DEFAULT_PICK = "orange";
const withPickerValue = withState("pickerValue", "setPickerValue", null);

// find matching option
const findOption = (options, ourLabel) =>
  lodashFind(options, option => option.label.toLowerCase() === ourLabel.toLowerCase());

const withAutoPicking = lifecycle({
  componentWillReceiveProps(nextProps) {
    // when value was already picked
    if (nextProps.pickerValue) {
      return;
    }
    // networkStatus changed from 1 to 7, meaning initial load finished successfully
    if (this.props.data.networkStatus === 1 && nextProps.data.networkStatus === 7) {
      const match = findOption(nextProps.data.options)
      if (match) {
        nextProps.setPickerValue(match);
      }
    }
  },
  componentWillMount() {
    const { pickerValue, setPickerValue, data } = this.props;
    if (pickerValue) {
      return;
    }
    // when Apollo query is resolved from cache,
    // it already has networkStatus 7 at mount time
    if (data.networkStatus === 7 && !data.error) {
      const match = findOption(data.options);
      if (match) {
        setPickerValue(match);
      }
    }
  },
});

const Component = props => (
  <Select
    loading={props.data.loading}
    value={props.pickerValue && props.pickerValue.value || null}
    onChange={props.setPickerValue}
    options={props.data.options || undefined}
  />
);

const enhancedComponent = compose(
  graphql(getOptions),
  withPickerValue,
  withAutoPicking,
)(Component);

Controlling pollInterval

This case is borrowed from David Glasser's post on the Apollo blog about the Meteor's Galaxy UI migrations panel implementation. In the post, he says:

We’re not usually running any migrations, so a nice, slow polling interval like 30 seconds seemed reasonable. But in the rare case where a migration is running, I wanted to be able to see much faster updates on its progress.

The key to this is knowing that the options parameter to react-apollo’s main graphql function can itself be a function that depends on its incoming React props. (The options parameter describes the options for the query itself, as opposed to React-specific details like what property name to use for data.) We can then use recompose's withState() to set the poll interval from a prop passed in to the graphql component, and use the componentWillReceiveProps React lifecycle event (added via the recompose lifecycle helper) to look at the fetched GraphQL data and adjust if necessary.

Let's look at the code:

import { graphql } from "react-apollo";
import gql from "graphql-tag";
import { compose, withState, lifecycle } from "recompose";

const DEFAULT_INTERVAL = 30 * 1000;
const ACTIVE_INTERVAL = 500;

const withData = compose(
  // Pass down two props to the nested component: `pollInterval`,
  // which defaults to our normal slow poll, and `setPollInterval`,
  // which lets the nested components modify `pollInterval`.
  withState("pollInterval", "setPollInterval", DEFAULT_INTERVAL),
  graphql(
    gql`
      query getMigrationStatus {
        activeMigration {
          name
          version
          progress
        }
      }
    `,
    {
      // If you think it's clear enough, you can abbreviate this as:
      //   options: ({pollInterval}) => ({pollInterval}),
      options: props => {
        return {
          pollInterval: props.pollInterval
        };
      }
    }
  ),
  lifecycle({
    componentWillReceiveProps({
      data: { loading, activeMigration },
      pollInterval,
      setPollInterval
    }) {
      if (loading) {
        return;
      }
      if (activeMigration && pollInterval !== ACTIVE_INTERVAL) {
        setPollInterval(ACTIVE_INTERVAL);
      } else if (
        !activeMigration &&
        pollInterval !== DEFAULT_INTERVAL
      ) {
        setPollInterval(DEFAULT_INTERVAL);
      }
    }
  })
);
const MigrationPanelWithData = withData(MigrationPanel);

Note that we check the current value of pollInterval before changing it because, by default in React, nested components will get re-rendered any time we change state, even if you change it to the same value. You can deal with this using shouldComponentUpdate or React.PureComponent, but in this case it’s straightforward just to only set the state when it’s actually changing.

Other use-cases

Recompose is a powerful tool and can be applied to all sorts of other cases. Here are a few final examples.

Normally, if you wanted to add side effects to the mutate function, you would manage them in the graphql HOC's props option by doing something like { mutate: () => mutate().then(sideEffectHandler) }. But that's not very reusable. Using recompose's withHandlers() you can compose the same prop manipulation in any number of components. You can see a more detailed example here.

Mutations can also be tracked using recompose's withState, since it has no effect on your query's loading state. For example, you could use it to disable buttons while submitting form data.

See the full Recompose docs here.