Functional programming is a hot topic in React development these days, and for good reason. At work, when I began using Recompose, “a React utility belt for function components and higher-order components” created by Andrew Clark, I liked the way Recompose instantly transformed the way I coded — and thought about coding — in React. Being function-friendly, Recompose increases the predictability and composability of React code and makes apps easier to test.
Here, I’ll walk you through how I refactored a React app with Recompose and, as a bonus, give you an introduction to React VR along the way! This article assumes you have a working knowledge of React.
Note: As of May 2018, React VR was revamped and rebranded as React 360.
The project I’ll guide you through is a virtual reality app which allows the user to choose his or her immersive meditation environment, each of which comes with its own mantra inspired by the very excellent show “The Good Place.” In case you need it for reference, my final source code is here.
About React VR
- Open source, built by Facebook
- Incorporates Three.js, a 3-D JavaScript library; React Native mobile elements; and Web VR, responsible for allowing us to view VR experiences across different browsers, including web
- Enables Flexbox for ease with fitting content to different browsers/screens
React VR components include (among others):
– View
– given to us by React Native, it’s used in place of the div
elements React typically expects and maps to the view of whatever platform is running the code
– Text
– given to us by React Native, it renders 3-D text
– Pano
– displays 360-degree panoramas
– DirectionalLight
– one of many types of light sources, it illuminates all objects equally from a given direction
– Sphere
– adds a 3-D sphere to your VR scene
– VrButton
You do not need any VR devices to get started with creating React VR apps.
Building your own FindYourZen
Step 1
- Install the React VR CLI tool:
$ npm i -g react-vr-cli
- Use it to create a new project:
$ react-vr init FindYourZen $ cd FindYourZen $ npm start
Step 2
Take a look at the file structure.
index.vr.js
= entry point for your appvr
folder = stores the code that launches your appstatic_assets
= stores images, audio files, and other external resources
Step 3
Delete the chess image from static_assets
and replace it with some new images, including your “home” environment image, the one that appears when the app loads.
A tip (Thank you, Coding Artist!): Search for “equirectangular” photos on Google. Flickr is a particularly good source of free, Creative Commons-licensed, high-quality panoramic photos.
Create an images
folder in your static_assets
and move your images in there. Update the Pano
component to refer to the home environment image for now.
As you may have guessed by the name, the Pano
component allows us to display panoramic, or “equirectangular,” images, and uses the asset
utility from react-vr
to automatically look inside our static_assets
folder for the image.
Step 4
Change the button text to “Choose your zen” and the color to a nice, calming blue. (I liked #29ECCE
).
Note that the button text — and, really, any text in the application — needs to be explicitly wrapped inside a Text
component.
Step 5
Now, you’ll need to add logic for updating the user’s environment based on which option they choose by clicking on a VrButton
component. I’ll leave this open-ended as I’m assuming readers can set up local React state in index.vr.js
on their own. If you need more guidance, though, I do include the original code setting up local state in my discussion of Recompose tools below. 🙂
As an FYI, I wound up putting my data for each environment in a consts/zens.js
file:
const zens = [ { id: 1, mantra: "Find your inner motherforking peace", image: "images/hawaii_beach.jpg", audio: "sounds/waves.mp3", text: "I'm feeling beachy keen", }, { id: 2, mantra: "Breathe in peace, breathe out bullshirt", image: "images/horseshoe_bend.jpg", audio: "sounds/birds.mp3", text: "Ain't no mountain high enough", }, { id: 3, mantra: "Benches will be benches", image: "images/sunrise_paris_2.jpg", audio: "sounds/chimes.mp3", text: "I want a baguette", }, { id: 4, image: "images/homebase.png", text: "Home" } ] export default zens;
Step 6
You don’t really feel like you’re at the beach unless you hear the sound of waves, right?
A good source of free and Creative Commons-licensed audio is Freesound. You’ll have to make an account, but it’s quick and easy. They’ll ask you to complete a survey along the way, but you can just skip it.
After downloading the sounds — many of which have large .wav
files — you’ll want to compress the files. I used All2MP3, which was easy to install and worked like a dream to turn my .wav
files into more manageable .mp3
files, which I then added to a sounds
folder in my static_assets
.
You’ll then add each sound link to a Sound
component, which takes a source
prop, the value of which — like Pano
— wraps the link in an asset
utility, allowing us to automatically look inside our static_assets
folder for the resources we need.
Debugging React VR
When you Inspect Element
, you’ll see that React VR bundles all its files into one giant blob that isn’t super easy to grok. Fortunately, because it supports sourcemaps, we can still access the original files, use debugger
, etc.
Refactoring with Recompose
I found this project a great opportunity to get more comfortable with Recompose. Recompose is all about currying, which basically means that one function takes a series of arguments and returns — for example — a function that uses one argument, which returns a function that uses another of the arguments, etc., until all the original arguments are used up. Here’s a theoretical example:
const madLibMantraGenerator = function(yogaPhrase, goodPlaceSwear) { return function(meditationWord) { console.log(yogaPhrase + goodPlaceSwear + meditationWord); } } const mantra = madLibMantraGenerator("breathe in", "motherforking"); mantra("namaste"); // => "breathe in motherforking namaste" // a.k.a. madLibMantraGenerator("breathe in," "motherforking")("namaste")
Staying relevant to our needs as React developers, Recompose uses Higher Order Components (HOCs) — functions that return functions that render React components — for its currying work.
$ npm i --save recompose
Using withState
and withHandlers
Thanks to Recompose, I was able to convert my MeditationApp
component in index.vr.js
to a stateless, functional component from a class component, thanks to Recompose’s withState
. withState
takes three arguments: the name of the state being updated (in my case, selectedZen
), the function or handler updating the state (zenClicked
), and the initial value of selectedZen
(4
, the ID for the home environment).
// previous component structure in index.vr.js import React from 'react'; import { AppRegistry, asset, Pano, VrButton, Text, View, Sound, Image, } from 'react-vr'; import zens from "./consts/zens.js"; import { ZenButton, Mantra, Title, HomeButton } from "./components/index.js"; export default class MeditationApp extends React.Component { constructor () { super(); this.state = { selectedZen: 4, } } zenClicked(zen) { let newZen = zen; this.setState({ selectedZen: newZen }); } render() { return ( <View> <Pano source={asset(zens[this.state.selectedZen - 1].image)}> <Sound source={asset(zens[this.state.selectedZen - 1].audio)} /> </Pano> <HomeButton text={zens[3].text} buttonClick={() => this.zenClicked(4)} /> { this.state.selectedZen !== 4 ? <Mantra text={zens[this.state.selectedZen - 1].mantra} /> : <View> <Title>Choose your zen</Title> <View> { zens.slice(0, 3).map((zen) => { return ( <ZenButton key={zen.id} buttonClick={() => this.zenClicked(zen.id)} text={zen.text} /> ) }) } </View> </View> } </View> ); } }; AppRegistry.registerComponent('MeditationApp', () => MeditationApp);
// present component structure import React from 'react'; import { AppRegistry, asset, Pano, VrButton, Text, View, Sound, Image, } from 'react-vr'; import zens from './consts/zens.js'; import { Audio, ZenButton, Mantra, Title, Menu, HomeButton } from './components/index.js'; import { withState, withHandlers, compose } from 'recompose'; const MeditationApp = compose( withState('selectedZen', 'zenClicked', 4), withHandlers({ zenClicked: (props) => (id, evt) => props.zenClicked(selectedZen => id) }), )(({ selectedZen, zenClicked }) => ( <View> <Pano source={asset(zens[selectedZen - 1].image)}> <Audio url={zens[selectedZen - 1].audio} /> </Pano> <HomeButton text={zens[3].text} buttonClick={() => zenClicked(4)} /> <Mantra text={zens[selectedZen - 1].mantra} /> <Title>Choose your zen</Title> <View> { zens.map((zen) => { return ( <ZenButton key={zen.id} buttonClick={() => zenClicked(zen.id)} text={zen.text} /> ) }) } </View> </View> )); AppRegistry.registerComponent('MeditationApp', () => MeditationApp);
In the code above, the result of withState
curries into withHandlers
, which accepts an object map of handler creators. Each creator takes a set of props and returns a handler to update state. Here, zenClicked
accepts a props
argument which returns an id
to be used as an argument in a curried function that calls props.zenClicked
(given to us by withState
) and updates the state of selectedZen
to the value of id
. Whew!
We can then use selectedZen
and zenClicked
in our functional component.
A note about withHandlers
and performance optimization: withHandlers
passes handlers as immutable props. Typically, functions added to the body of a class component are remade on every render in React, creating false positives and breaking lifecycle methods like shouldComponentUpdate
.
Hiding elements with branch
and renderNothing
This is looking good! But what about that ternary operator/if ... else
-like logic that either renders the home menu or a meditation-environment-based Mantra
? And what about that edge case I just thought of, in which I want to render the Home
button only when we are in a meditation environment (as opposed to within the home environment itself)? What about that annoying 404
error I see in my console when I’m in the home environment, regarding a nonexistent audio file?
I could keep adding ternary operators, which is fine and it works, but I’m more interested in extracting the logic from components’ render methods and putting it another HOC instead, which would be more functional and allow each component to be more individually focused on its own render logic.
Recompose comes to the rescue again! Rather than using if ... else
— which can get messy and easily lead to errors — or ternary operators, I can use Recompose’s branch
utility, which accepts a callback as an argument and returns one (or one of two) higher order components based on whether the callback function returns true
or false
. If I want the component to simply not display, given a certain condition, I can use Recompose’s renderNothing
, which will do exactly what it sounds like. Beautiful!
In my case. I set up a generic hideIf
provider:
// providers/hideIf.js import React from 'react'; import { branch, renderNothing } from 'recompose'; const hideIf = (callback) => branch( callback, renderNothing, ); export default hideIf;
Now, for instance, I can create a Menu
component that wraps the components I want to display in the home environment and set up a special hideIf
function for it:
// components/menu.js import React from 'react'; import { hideIf } from '../providers/index.js'; import { compose } from 'recompose'; import { View } from 'react-vr'; const hideMenu = hideIf((props) => props.selectedZen !== 4); export default compose( hideMenu, )((props) => { return ( <View style={{marginTop: -0.2, height: 0.2}}> {props.children} </View> ) });
// index.vr.js import React from 'react'; import { AppRegistry, asset, Pano, VrButton, Text, View, Sound, Image, } from 'react-vr'; import zens from './consts/zens.js'; import { Audio, ZenButton, Mantra, Title, Menu, HomeButton } from './components/index.js'; import { withState, withHandlers, compose } from 'recompose'; const MeditationApp = compose( withState('selectedZen', 'zenClicked', 4), withHandlers({ zenClicked: (props) => (id, evt) => props.zenClicked(selectedZen => id) }), )(({ selectedZen, zenClicked }) => ( <View> <Pano source={asset(zens[selectedZen - 1].image)}> <Audio url={zens[selectedZen - 1].audio} /> </Pano> <HomeButton text={zens[3].text} buttonClick={() => zenClicked(4)} /> <Mantra text={zens[selectedZen - 1].mantra} /> <Menu selectedZen={selectedZen}> <Title>Choose your zen</Title> <View> { zens.map((zen) => { return ( <ZenButton key={zen.id} buttonClick={() => zenClicked(zen.id)} text={zen.text} /> ) }) } </View> </Menu> </View> )); AppRegistry.registerComponent('MeditationApp', () => MeditationApp);
Cool, so that takes care of the Menu
logic. It appears in the home environment and disappears when I navigate to a meditation environment.
What about hiding the Mantra
component when it doesn’t have a value in the consts/zens.js
file (like in the home environment)? Easy:
// components/mantra.js import React from 'react'; import { Text } from 'react-vr'; import { hideIf } from '../providers/index.js'; import { compose } from 'recompose'; const hideMantra = hideIf((props) => props.text === null || props.text === undefined || props.text.length === 0); export default compose( hideMantra, )((props) => { const { text } = props; 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> ) });
After implementing similar logic for my HomeButton
component and Sound
(which becomes a wrapping Audio
) component, I get the results I want and no longer see any pesky errors in the console!
// components/home-button.js import React from 'react'; import { VrButton, Text, View, } from 'react-vr'; import { compose } from 'recompose'; import { hideIf } from '../providers/index.js'; const hideHomeButton = hideIf((props) => props.selectedZen === 4); export default compose ( hideHomeButton, )(({text}) => { return ( <VrButton onClick={props.buttonClick} style={{width: 1.0}}> <View style={{marginBottom: 0.2}} > <Text style={{ backgroundColor: 'white', color: '#29ECCE', fontSize: 0.07, marginTop: 0.05, layoutOrigin: [0.5, 0.5], fontWeight: '400', flexDirection: 'column', alignItems: 'stretch', justifyContent: 'flex-start', textAlign: 'center', textAlignVertical: 'center', transform: [{translate: [0, 0, -3]}], }}> {text} </Text> </View> </VrButton> ) });
// components/audio.js import React from 'react'; import { Sound } from 'react-vr'; import { compose } from 'recompose'; import { asset } from 'react-vr'; import { hideIf } from '../providers/index.js'; const hideIfNoUrl = hideIf((props) => props.url === null || props.url === undefined || props.url.length === 0); export default compose( hideIfNoUrl, )((props) => { const { url } = props; return ( <Sound source={asset(url)} /> ) });
I can also implement something similar in my ZenButton
component to avoid having to slice
my zens when I map through them in index.vr.js
:
// components/zen-button.js import React from 'react'; import { VrButton, Text, View, } from 'react-vr'; import { hideIf } from "../providers/index.js"; import { compose } from 'recompose'; const hideHomeOption = hideIf((props) => props.text === "Home"); export default compose( hideHomeOption, )((props) => { const { text } = props; return ( <VrButton onClick={props.buttonClick} style={{width: 1.0}}> <Text style={{ backgroundColor: '#29ECCE', fontSize: 0.07, marginTop: 0.03, flexDirection: 'column', alignItems: 'stretch', justifyContent: 'flex-start', layoutOrigin: [0.5, 0.5], fontWeight: '400', textAlign: 'center', textAlignVertical: 'center', transform: [{translate: [0, 0, -1]}], }}> {text} </Text> </VrButton> ) });
Hmm, this seems like a code smell: What if the text of the HomeButton
changes? It’s probably best to make determinations based on environment ID. It also seems like both HomeButton
and ZenButton
need to renderNothing
in the home environment. I’m going to add a selectedZen
prop to ZenButton
so that it, like HomeButton
, can be aware of the selectedZen.
I create a top-level hideIfHome
HOC in my providers
folder that makes use of the previously created hideIf
:
import React from 'react'; import hideIf from './hideIf'; const hideIfHome = hideIf(({ selectedZen }) => selectedZen === 4); export default hideIfHome;
Now I can replace the repetitive providers in both HomeButton
and ZenButton
with this one!
Unfortunately, there’s still some anti-DRY (Don’t Repeat Yourself) logic in both components. Let’s create a components/buttons
folder and set up a baseButton
that will use the hideIfHome
provider and set some base styles that both ZenButton
and HomeButton
can draw from:
// components/buttons/base-button.js import React from 'react'; import { VrButton, Text, View, } from 'react-vr'; import { compose } from 'recompose'; import { hideIfHome } from '../../providers/index.js'; export default compose( hideIfHome, )((props) => { const { text, textStyle } = props; return ( <VrButton onClick={props.buttonClick} style={{ width: 1.0 }}> <Text style={[ { fontSize: 0.07, flexDirection: 'column', alignItems: 'stretch', justifyContent: 'flex-start', layoutOrigin: [0.5, 0.5], fontWeight: '400', textAlign: 'center', textAlignVertical: 'center' }, textStyle, ]}> // in React Native, you can combine styles by passing them in an array, with the last style passed taking precedence {text} </Text> </VrButton> ) });
// component/buttons/zen-button.js import React from 'react'; import { VrButton, Text, View, } from 'react-vr'; import BaseButton from './base-button.js'; const ZenButton = ({ text, buttonClick, selectedZen }) => { return ( <BaseButton text={text} selectedZen={selectedZen} buttonClick={buttonClick} textStyle={{ backgroundColor: '#29ECCE', color: 'white', marginTop: 0.03, transform: [{translate: [0, 0, -1]}] }} /> ) } export default ZenButton;
// components/buttons/home-button.js import React from 'react'; import { VrButton, Text, View, } from 'react-vr'; import BaseButton from './base-button.js'; const HomeButton = ({ text, buttonClick, selectedZen }) => { return ( <BaseButton selectedZen={selectedZen} buttonClick={buttonClick} text={text} textStyle={{ backgroundColor: 'white', color: '#29ECCE', marginTop: 0.05, transform: [{translate: [0, 0, -3]}]}} /> ) } export default HomeButton;
Be sure to update your paths for ZenButton
and HomeButton
in components/index.js
.
What else can Recompose do?
If you’re like me, the fact that you’re still passing props down through more than one component is making you itchy. For a more Redux-like approach to state management, look into Recompose’s withReducer
, getContext
and withContext
utilities. This will be the subject of a future post.
Recompose also comes with 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.
Useful React VR resources and tutorials
Useful Recompose resources