Compartmentalizing Data Flow with React HOCs with TypeScript and Recompose

Apr 29, 2018

An Inconvenient Truth: Prop Drilling

We've all worked on a React app where a component will take in multiple props only to pass them through to another component, which passes them through to another one, so on so forth. This is called Prop Drilling. We're going through each component as a "layer" in the app. To rehash a tired old idea:

import * as React from "react";
//example model import
import TodoItem from "../models";

interface OwnProps {
    items: TodoItem[];
}

export default TodoApp extends React.Component<OwnProps, {}> {
    render () {
        return (
            <h1>To Do App</h1>
            <TodoList items={items}/>
        )
    }
}

// TodoItem.tsx
import * as React from "react";
import TodoItem from "../models";
import ListItem from "../list-item";

interface TodoProps {
    items: TodoItem[];
}

const TodoList: React.SFC<TodoProps> = ({items}) => (
    {
        items.map( (item) => (<ListItem item={item}/>);
    }
);

// ListItem.tsx
import * as React from "react";
import TodoItem from "../models";

interface ListItemProps {
    item: TodoItem;
}

const ListItem: React.SFC<ListItemProps> = ({item}) => (
    <div>
        <span>{item.name}</span>
        <span>{item.isCompleted</span>
    </div>
);

That's a lot to go through and really follow along, especially if it's in a larger application. What if there was a better way to do this? We can do this with Recompose. To test out the versatility of Recompose, let's make an application that queries the Pokemon API and renders out Charmander's information.

The source code is here.

Recompose

Recompose is a set of tools that work very well with React's ability to create functional components. It allows you to compose multiple functions together to return a value. In Recompose's case, most likely a React component that's hydrated by the data that's passed through our chain of functions. Let's take a look at a basic Recompose utility: branch().

Per Recompose docs, branch is a function that takes in a predicate function and two HOC's in that order. It runs the predicate function and returns the first HOC if it returns true, and returns the second if it returns false. Let's use this to create a loading component that checks to see if data is loading from our API.

import { branch, renderComponent } from "recompose";
import Loader from "../loader";
import { StateProps } from "../app";

const whileLoading = (WrappedComponent: React.ComponentType) => branch(
    (props: StateProps) => props.data === null,
    renderComponent(Loader),
)(WrappedComponent);

export default whileLoading;

Where Loader is our React component that contains the loader itself. When we curry this into a larger component using Recompose, we can take advantage of this logic in other places, thereby creating a reusable HOC! Let's see how it works here.

import * as React from "react";
import { compose, withProps } from "recompose";
import withData from "./providers/withData";
import whileLoading from "./providers/whileLoading";
import { Wrapper, Title } from "./style";

export interface StateProps {
    data: {
        name: string;
        weight: number;
    };
}

export default compose<StateProps, {}>(
    withData,
    whileLoading,
)(({
    data: {
        name,
        weight,
    }
}) => (
        <Wrapper>
            <Title>pokemon: {name}</Title>
            <Title>weight: {weight}</Title>
        </Wrapper>
    ),
);

Now here, our data can be compartmentalized. This means that even though you have a large data set, you can have smaller components that are only aware of what data that it needs through recompose. Plus, with the power of Recompose, it's extremely easy to share your HOC's across multiple components. Let's make another component within our app that iterates out Charmander's moves.

import * as React from "react";
import { withData, whileLoading } from "../providers";
import { compose } from "recompose";
import { List, ListItem } from "./style";

interface StateProps {
    data: {
        moves: {
            move: {
                name,
            }
        }[];
    }
}

export default compose<StateProps, {}>(
    withData,
    whileLoading,
)(({
    data: {
        moves,
    }
}) => (
    <List>
        {
            moves.map( (move, index) => <ListItem key={index}>{move.move.name}</ListItem>)
        }
    </List>
));

Let's add it to our app.tsx:

import MoveList from "./components/move-list";

// ...component
<Wrapper>
    <Title>pokemon: {props.data.name}</Title>
    <Title>weight: {props.data.weight}</Title>
    <MoveList/>
</Wrapper>

Pretty cool right? All we need to do is compose our functions together and we have our data ready to go. Another benefit is that everything is sharable and thus much easier for team-based work without stepping on everyone's toes.

However I do see a problem:
double requests!

We're duplicating requests! This is a problem, especially if we're making multiple POST calls. Let's take advantage of contextual data, also made available to us from Recompose via withContext and getContext. What we'll do now is ensure that our calls will only be made once, and provide that context throughout our child components.

Establishing Context

We'll start with establishing our context as follows:

import { withContext, compose } from "recompose";
import * as PropTypes from "prop-types";
import withData from "./withData";
import whileLoading from "./whileLoading";

export const APIPropTypes = {
    data: PropTypes.object,
};

export interface APIContext {
    data: {
        name: string;
        weight: number;
        moves: {
            move: {
                name: string;
            }
        }[];
    };
}

const context = withContext<APIContext, APIContext>(
    APIPropTypes,
    ({data}) => ({
        data,
    })
);

export default compose(
    withData,
    whileLoading,
    context,
);

Then we'll create a function in a separate file that injects the context to our children:

import { getContext } from "recompose";
import { APIContext, APIPropTypes } from "./withAPIContext";

export default getContext<APIContext>(APIPropTypes);

Then we can replace our withData functions and curry our data all at once! Due to the flexibility of injecting our data into a HOC that provides context to all components that ask for it through our child HOC's:

// app.tsx
import * as React from "react";
import { Wrapper } from "./style";
import MoveList from "./components/move-list";
import { withApiContext } from "./providers";
import Details from "./components/details";

export default withApiContext(() => (
    <Wrapper>
        <Title>pokemon: {props.data.name}</Title>
        <Title>weight: {props.data.weight}</Title>
        <MoveList/>
    </Wrapper>
))
// move-list.tsx
import * as React from "react";
import { usingApiContext } from "../providers";
import { List, ListItem } from "./style";

export default usingApiContext(({
    data: {
        moves,
    }
}) => (
    <List>
        {
            moves.map( (move, index) => <ListItem key={index}>{move.move.name}</ListItem>)
        }
    </List>
))

This way we can reduce our data queries and inject a destructred subset of our entire dataset to the components that ask for the parent context. Another benefit of doing this with TypeScript is that everything is typed because the context provider is taking in an interface that matches our data. This is a huge boon especially if you are working with other developers on a team.

Let's refactor our two other <Title>'s to use our context provider:

// details.tsx
import * as React from "react";
import { usingApiContext } from "../providers";
import { Title } from "./style";

export default usingApiContext(({
    data: {
        name,
        weight,
    }
}) => (
    <>
        <Title>Pokemon: {name}</Title>
        <Title>Weight: {weight}</Title>
    </>
));
// app.tsx
import * as React from "react";
import { Wrapper } from "./style";
import MoveList from "./components/move-list";
import { withApiContext } from "./providers";
import Details from "./components/details";

export default withApiContext(() => (
    <Wrapper>
        <Details/>
        <MoveList/>
    </Wrapper>
))

Super nice, right? The component has become more declarative and has only the data that it needs. You can view the source code here.

Conclusion