Recompose with React VR, Pt. 2

Recently, I published a post introducing Recompose — a utility belt for React — in a React VR meditation app called Find Your Zen. You can find the original post here. It covers some awesome aspects of Recompose and functional programming in general. We wound up with a refactored application, but as with all programming projects, there’s still more we can do to improve the code!

In our case, when we left off with our work last time, we were still drilling the same props down through more than one component. How annoying! Fortunately, Recompose gives us the tools to fix that.

Using withContext and getContext

In Reactland, we have this handy tool called context, which — the React docs put it nicely — “provides a way to pass data through the component tree without having to pass props down manually at every level.”

Recompose’s withContext, in conjunction with its getContext, allows us to easily and cleanly provide context to the children of a component (often the app’s main component). getContext, used in a child component, allows us to pull out useful information from the parent context and provide it to the component at hand — No need to drill props down through the app!

Let’s first create a provider called withStateAndHandlers.js and move your withState and withHandlers composition logic in there. This will allow us to share state and handlers as part of our context.

// providers/withStateAndHandlers.js
import React from 'react';
import { withState, withHandlers, compose } from 'recompose';

const withStateAndHandlers = compose(
    withState('selectedZen', 'zenClicked', 4),
    withHandlers({
        zenClicked: (props) => (id, evt) => props.zenClicked(selectedZen => id)
    }),
)

export default withStateAndHandlers;

Next, create a withAppContext provider that will use Recompose’s withContext and our previously defined withStateAndHandlers. withContext takes in two arguments: an object of React prop types (childContextTypes) and a function that returns the child context (getChildContext) so we can access it as needed.

If you’re more familiar with traditional React state management via Redux, Recompose’s withAppContext essentially allows us to utilize a Provider pattern similar to that in Redux. We will wind up wrapping our main MeditationApp component with our withAppContext provider.

Note: Recompose requires us to use prop types to define the shape of our app’s context data (even if we’re already using TypeScript or Flow).

// providers/withAppContext.js
import { withContext, compose } from 'recompose';
import * as PropTypes from 'prop-types';
import withStateAndHandlers from './withStateAndHandlers';

export const AppPropTypes = {
    selectedZen: PropTypes.number,
    zenClicked: PropTypes.func,
}

const AppContext = withContext(
    AppPropTypes,
    ({ selectedZen, zenClicked }) => ({
        selectedZen,
        zenClicked,
    })
);

export default compose(
    withStateAndHandlers,
    AppContext,
);

And, finally, we’ll construct a usingAppContext provider which draws on Recompose’s getContext and our previously defined prop types.

// providers/usingAppContext.js
import { getContext } from 'recompose';
import { AppPropTypes } from "./withAppContext";

export default getContext(AppPropTypes);

Now, you can access the context anywhere you desire within your app. I was able to replace my Pano component with a WrappedPano component responsible for retrieving its own data via usingAppContext:

// components/wrapped-pano.js
import React from 'react';
import { Pano } from 'react-vr';
import { usingAppContext } from '../providers/index.js';
import { Audio } from '../components/index.js';
import zens from '../consts/zens.js';
import { asset } from 'react-vr';

export default usingAppContext(({ selectedZen }) => {
    return (
        <Pano source={asset(zens[selectedZen - 1].image)} >
            <Audio />
        </Pano>
    )
});

While I could simply pass the same selectedZen prop down to Audio here, I’d like to make it similarly self-contained with regards to its data. I refactored it as such:

// components/audio.js
import React from 'react';
import { Sound } from 'react-vr';
import zens from '../consts/zens.js';
import { compose } from 'recompose';
import { asset } from 'react-vr';
import { hideIf, usingAppContext } from '../providers/index.js';

const hideIfNoAudioUrl = hideIf(({ selectedZen }) => {
    const zenAudio = zens[selectedZen - 1].audio;
    return zenAudio === null || zenAudio === undefined || zenAudio.length === 0;
});

export default compose(
    usingAppContext,
    hideIfNoAudioUrl,
)(({ selectedZen }) => {
    const zenAudio = zens[selectedZen - 1].audio;
    return (
        <Sound source={asset(zenAudio)} />
    )
});

You’ll notice I was able to change the hideIf provider here not to evaluate a url prop but to use the selectedZen value directly from context.

I can then compose usingAppContext with other components dependent on the selectedZen information — Menu, Mantra, and HomeButton.

// components/menu.js
import React from 'react';
import { hideIf, usingAppContext } from '../providers/index.js';
import { compose } from 'recompose';
import { View } from 'react-vr';
import { Zens, Title } from '../components/index.js';

const hideMenu = hideIf(({ selectedZen }) => selectedZen !== 4);

export default compose(
    usingAppContext,
    hideMenu,
)(({ selectedZen, children }) => {
    return (
        <View style={{marginTop: -0.2, height: 0.2}}>
            { children }
        </View>
    )
});
// components/mantra.js
import React from 'react';
import { Text } from 'react-vr';
import zens from '../consts/zens.js';
import { hideIfHome, usingAppContext } from '../providers/index.js';
import { compose } from 'recompose';

export default compose(
    usingAppContext,
    hideIfHome,
)(({ selectedZen }) => {
    const text = zens[selectedZen - 1].mantra;
    return (
        <Text
            style={{
              backgroundColor: 'transparent',
              color: 'lightcyan',
              fontSize: 0.3,
              fontWeight: '500',
              layoutOrigin: [0.5, 0.5],
              paddingLeft: 0.2,
              paddingRight: 0.2,
              textAlign: 'center',
              textAlignVertical: 'center',
              transform: [{translate: [0, 0, -3]}],
          }}>
            { text }
        </Text>
    )
});
// components/button/home-button.js
import React from 'react';
import {
  VrButton,
  Text,
  View,
} from 'react-vr';
import BaseButton from './base-button.js';
import { usingAppContext } from '../../providers/index.js';
import zens from '../../consts/zens.js';

export default usingAppContext(({ selectedZen, zenClicked }) => {
  return (
    <View style={{marginBottom: 0.2}}>
      <BaseButton
        selectedZen={selectedZen}
        buttonClick={() => zenClicked(4)}
        text={zens[3].text}
        textStyle={{
          backgroundColor: 'white',
          color: '#29ECCE',
          marginTop: 0.05,
          transform: [{translate: [0, 0, -3]}]}}
      />
    </View>
  )
});

I can also make the choice to separate the mapping of the zens into ZenButtons out into its own component, Zens.js, which now gets the click handler passed down from context:

// index.vr.js
import React from 'react';
import { ZenButton } from '../components/index.js';
import { usingAppContext } from '../providers/index.js';
import zens from '../consts/zens.js';
import { compose } from 'recompose';
import { View } from 'react-vr';

export default compose(
    usingAppContext
)(({ zenClicked }) => {
    return (
        <View>
        {
            zens.map((zen) => (
                <ZenButton
                    selectedZen={zen.id}
                    key={zen.id}
                    buttonClick={() => zenClicked(zen.id)}
                    text={zen.text}
                />
            ))
        }
    </View>
    )
})

My updated index.vr.js component now is no longer responsible for passing data down to its child components. How refreshing!

import React from 'react';
import {
  AppRegistry,
  asset,
  Pano,
  VrButton,
  Text,
  View,
  Sound,
  Image,
} from 'react-vr';
import zens from './consts/zens.js';
import { Zens, Mantra, Title, Menu, HomeButton, WrappedPano } from './components/index.js';
import { withState, withHandlers, compose } from 'recompose';
import { withAppContext } from './providers/index.js';

const MeditationApp = withAppContext(() => (
    <View>
      <WrappedPano />
      <HomeButton />
      <Mantra />
      <Menu>
          <Title>Choose your zen</Title>
          <Zens />
      </Menu>
  </View>
));

AppRegistry.registerComponent('MeditationApp', () => MeditationApp);

If I’ve managed to make you fall in love with context but not functional programming (sad face), check out this helpful article on the Context API. Once experimental, it’s been officially rolled into the newest React release. Redux’s Provider draws on this exact API, and it’s now stable enough for you to use it safely as a standalone device to help manage/access app state.

What else can Recompose do?

Recompose also comes with withReducer, mapProps, which works similarly to React-Redux‘s mapStateToProps, and a lifecycle utility for adding lifecycle methods such as componentDidMount to functional components.

Viewing the finished demo code

$ git clone https://github.com/lilybarrett/find-your-zen.git
$ cd find-your-zen
$ npm i
$ npm start

Navigate to http://localhost:8081/vr/index.html.

Note: As of May 2018, React VR has been revamped and rebranded as React 360. I plan to port my project over soon.

Useful Recompose resources


Leave a Reply

Please log in using one of these methods to post your comment:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s