Animating with React, Redux, and d3
And now for some pure nerdy fun: A particle generator⦠or, well, as close as you can get with React and D3. You'd need WebGL for a real particle generator.
We're making tiny circles fly out of your mouse cursor. Works on mobile with your finger, too.
To see the particle generator in full action, go here. Embedded below for your convenience. Github won't let me host different branches, so you'll see the advanced 20,000 particle version from next chapter.
We're using the game loop approach to animation and Redux to store the state tree and drive changes for each frame.
You can see the full code on GitHub. I've merged the SVG and Canvas branches. Redux part is the same, some parameters differ. We're focusing on the Redux part because that's what's new.
It's going to be great.
Code in this example uses the .jsx
file extension. I originally wrote
it back when that was still a thing, and while I did update everything to React
16+, I felt that changing filenames was unnecessary.
Here's how it works
We use React to render everything: the page, the SVG element, the particles inside. This lets us tap into React's algorithms that decide which nodes to update and when to garbage collect old nodes.
Then we use some d3 calculations and event detection. D3 has great random
generators, so we take advantage of that. D3's mouse and touch event handlers
calculate coordinates relative to our SVG. We need those, but React's click
handlers are based on DOM nodes, which don't correspond to (x, y)
coordinates. D3 looks at real cursor position on screen.
All particle coordinates are in a Redux store. Each particle also has a movement vector. The store holds some useful flags and general parameters, as well. This lets us treat animation as data transformations. I'll show you what that means.
We use actions to communicate user events like creating particles, starting the animation, changing mouse position, and so on. On each requestAnimationFrame, we dispatch an "advance animation" action.
On each action, the reducer calculates a new state for the whole app. This includes new particle positions for each step of the animation.
When the store updates, React flushes changes via props and because coordinates are state, the particles move.
The result is smooth animation. Just like the game loop example from before.
Some basic terminology
We're about to throw around some terms. You'll understand what they mean in detail after this chapter.
Until then, here's a quick glossary so you don't feel lost:
The store, or the state, is Redux state. Held globally and shared by all components. It's like the App local state from earlier chapters.
Actions are packets of information that tell us what happened.
Reducers are functions that take the current state and an action, and use that information to generate a new state.
Got it? No worries. You will soon π
3 presentation components
We start with the presentation components because they're the simplest. To render a collection of particles, we need:
- a stateless
Particle
- a stateless
Particles
- a class-based
App
None of them contain state, but App
has to be a class-based component so that
we can use componentDidMount
. We need it to attach D3 event listeners.
Particle
The Particle
component is a circle. It looks like this:
// src/components/Particles/Particle.jsximport React from "react"const Particle = ({ x, y }) => <circle cx={x} cy={y} r="1.8" />export default Particle
It takes x
and y
coordinates and returns an SVG circle.
Particles
The Particles
component isn't much smarter β it returns a list of circles
wrapped in a grouping element, like this:
// src/components/Particles/index.jsximport React from "react"import Particle from "./Particle"const Particles = ({ particles }) => (<g>{particles.map((particle) => (<Particle key={particle.id} {...particle} />))}</g>)export default Particles
Walk through the array of particles, render a Particle component for each. Declarative rendering that you've seen before π
We can take an array of {id, x, y}
objects and render SVG circles. Now comes
our first fun component: the App
.
App
App
takes care of rendering the scene and attaching d3 event listeners. It
gets actions via props and ties them to mouse events. You can think of actions
as callbacks that work on the global data store directly. No need to pass them
through many levels of props.
The rendering part looks like this:
// src/components/index.jsximport React, { Component } from "react"import { select as d3Select, mouse as d3Mouse, touches as d3Touches } from "d3"import Particles from "./Particles"import Footer from "./Footer"import Header from "./Header"class App extends Component {// ..render() {return (<divonMouseDown={(e) => this.props.startTicker()}style={{ overflow: "hidden" }}><Header /><svgwidth={this.props.svgWidth}height={this.props.svgHeight}ref="svg"style={{ background: "rgba(124, 224, 249, .3)" }}><Particles particles={this.props.particles} /></svg><Footer N={this.props.particles.length} /></div>)}}export default App
There's more going on, but the gist is that we return a <div>
with a
Header
, a Footer
, and an <svg>
. Inside <svg>
, we use Particles
to
render many circles. The Header and Footer components are just some helpful
text.
Notice that the core of our rendering function only says "Put all Particles here, please". There's nothing about what's moved, what's new, or what's no longer needed. We donβt have to worry about that.
We get a list of coordinates and naively render some circles. React takes care of the rest.
Oh, and we call startTicker()
when a user clicks on our scene. No reason to
have the clock running before any particles exist.
D3 event listeners
To let users generate particles, we have to wire up some functions in
componentDidMount
. That looks like this:
// src/components/index.jsxclass App extends Component {svgWrap = React.createRef();componentDidMount() {let svg = d3Select(this.svgWrap.current);svg.on('mousedown', () => {this.updateMousePos();this.props.startParticles();});svg.on('touchstart', () => {this.updateTouchPos();this.props.startParticles();});svg.on('mousemove', () => {this.updateMousePos();});svg.on('touchmove', () => {this.updateTouchPos();});svg.on('mouseup', () => {this.props.stopParticles();});svg.on('touchend', () => {this.props.stopParticles();});svg.on('mouseleave', () => {this.props.stopParticles();});}updateMousePos() {let [x, y] = d3Mouse(this.svgWrap.current);this.props.updateMousePos(x, y);}updateTouchPos() {let [x, y] = d3Touches(this.svgWrap.current)[0];this.props.updateMousePos(x, y);}
There are several events we take into account:
mousedown
andtouchstart
turn on particle generationmousemove
andtouchmove
update the mouse locationmouseup
,touchend
, andmouseleave
turn off particle generation
Inside our event callbacks, we use updateMousePos
and updateTouchPos
to
update Redux state. They use d3Mouse
and d3Touches
to get (x, y)
coordinates for new particles relative to our SVG element and call Redux
actions passed-in via props. The particle generation step uses this data as
each particle's initial position.
You'll see that in the next section. I agree, it smells kind of convoluted, but it's for good reason: We need a reference to a mouse event to get the cursor position, and we want to decouple particle generation from event handling.
Remember, React isn't smart enough to figure out mouse position relative to our drawing area. React knows that we clicked a DOM node. D3 does some magic to find exact coordinates.
Touch events return lists of coordinates. One for each finger. We use only the first coordinate because shooting particles out of multiple fingers would make this example too hard.
That's it for rendering and user events. 107 lines of code.
6 Redux Actions
Redux actions are a fancy way of saying "Yo, a thing happened!". They're functions you call to get structured metadata that's passed into Redux reducers.
Our particle generator uses 6 actions:
tickTime
steps our animation to the next frametickerStarted
fires when everything beginsstartParticles
fires when we hold down the mousestopParticles
fires when we releaseupdateMousePos
keeps mouse position saved in stateresizeScreen
saves new screen size so we know where edges lie
Our actions look something like this:
export function updateMousePos(x, y) {return {type: UPDATE_MOUSE_POS,x: x,y: y,}}
A function that accepts params and returns an object with a type and meta data. Technically this is an action generator and the object is an action, but that distinction has long since been lost in the community.
Actions must have a type
. Reducers use the type to decide what to do. The
rest is optional.
You can see all the actions on GitHub.
I find this to be the least elegant part of Redux. Makes sense in large applications, but way too convoluted for small apps. Simpler alternatives exist like doing it yourself with React Context.
1 Container component
Containers are React components that talk to the Redux data store.
You can think of presentation components as templates that render stuff and containers as smart-ish views that talk to controllers. Or maybe they're the controllers.
Sometimes it's hard to tell. In theory presentation components render and don't think, containers communicate and don't render. Redux reducers and actions do the thinking.
I'm not sure this separation is necessary in small projects.
Maintaining it can be awkward and sometimes cumbersome in mid-size projects, but I'm sure it makes total sense at Facebook scale. We're using it in this project because the community has decided that's the way to go.
We use the idiomatic connect()
approach. Like this:
// src/containers/AppContainer.jsximport { connect } from "react-redux"import React, { Component } from "react"import * as d3 from "d3"import App from "../components"import {tickTime,tickerStarted,startParticles,stopParticles,updateMousePos,} from "../actions"class AppContainer extends Component {startTicker = () => {const { isTickerStarted } = this.propsif (!isTickerStarted) {console.log("Starting ticker")this.props.tickerStarted()d3.timer(this.props.tickTime)}}render() {const { svgWidth, svgHeight, particles } = this.propsreturn (<AppsvgWidth={svgWidth}svgHeight={svgHeight}particles={particles}startTicker={this.startTicker}startParticles={this.props.startParticles}stopParticles={this.props.stopParticles}updateMousePos={this.props.updateMousePos}/>)}}const mapStateToProps = ({generateParticles,mousePos,particlesPerTick,isTickerStarted,svgWidth,svgHeight,particles,}) => ({generateParticles,mousePos,particlesPerTick,isTickerStarted,svgWidth,svgHeight,particles,})const mapDispatchToProps = {tickTime,tickerStarted,startParticles,stopParticles,updateMousePos,}export default connect(mapStateToProps, mapDispatchToProps)(AppContainer)
I love the smell of boilerplate in the morning. :nose:
We import dependencies and define AppContainer
as a class-based React
Component
so we have somewhere to put the D3 interval. The render method
outputs our <App>
component using a bunch of props to pass relevant actions
and values.
The startTicker
method is a callback we pass into App. It runs on first click
and starts the D3 interval if necessary. Each interval iteration triggers the
tickTime
action.
AppContainer talks to the store
// src/containers/AppContainer.jsxconst mapStateToProps = ({generateParticles,mousePos,particlesPerTick,isTickerStarted,svgWidth,svgHeight,particles,}) => ({generateParticles,mousePos,particlesPerTick,isTickerStarted,svgWidth,svgHeight,particles,})const mapDispatchToProps = {tickTime,tickerStarted,startParticles,stopParticles,updateMousePos,}export default connect(mapStateToProps, mapDispatchToProps)(AppContainer)
We're using the connect()
idiom to connect our AppContainer to the Redux
store. It's a higher order component that handles all the details of connection
to the store and staying in sync.
We pass two arguments into connect. This returns a higher order component function, which we wrap around AppContainer.
The first argument is mapStateToProps. It accepts current state as an argument, which we immediately deconstruct into interesting parts, and returns a key:value dictionary. Each key becomes a component prop with the corresponding value.
You'd often use this opportunity to run ad-hoc calculations, or combine parts of state into single props. No need for that in our case, just pass it through.
Dispatching actions
mapDispatchToProps is a dictionary that maps props to actions. Each prop
turns into an action generator wrapped in a store.dispatch()
call. To fire an
action inside a component we just call the function in that prop.
But Swiz, we're not writing key:value dictionaries, we're just listing stuff!
That's a syntax supported in most modern JavaScript environments, called object
literal property value shorthand. Our build system expands that
mapDispatchToProps
dictionary into something like this:
const mapDispatchToProps = {tickTime: tickTime,tickerStarted: tickerStarted,startParticles: startParticles,stopParticles: stopParticles,updateMousePos: updateMousePos,}
And you thought previous code had a lot of boilerplate ... imagine if this was how you'd do it in real life :stuck_out_tongue:
connect
wraps each of these action generators in store.dispatch()
calls.
You can pass the resulting function into any component and fire actions by
calling that method.
The Redux loop
To make a change therefore, a Redux loop unfolds:
- Call our action triggerer, passed in through props
- Calls the generator, gets a
{type: ...}
object - Dispatches that object on the store
- Redux calls the reducer
- Reducer creates new state
- Store updates triggering React's engine to flow updates through the props
So that's the container. 71 lines of boilerplate pretty code.
The remaining piece of the puzzle is our reducer. Two reducers in fact.
2 Redux Reducers
With the actions firing and the drawing done, it's time to look at the business logic of our particle generator. We'll get it done in just 33 lines of code and some change.
Well, it's a bunch of change. But the 33 lines that make up CREATE_PARTICLES
and TIME_TICK
changes are the most interesting. The rest just flips various
flags.
All our logic and physics goes in the reducer.
Dan Abramov says to think of
reducers as the function you'd put in .reduce()
. Given a state and a set of
changes, how do I create the new state?
A "sum numbers" example would look like this:
let sum = [1, 2, 3, 4].reduce((sum, n) => sum + n, 0)
For each number, take the previous sum and add the number.
Our particle generator is a more advanced version of the same concept: Takes current application state, incorporates an action, and returns new application state.
Start with a default state and some D3 random number helpers.
import { randomNormal } from "d3";const Gravity = 0.5,randNormal = randomNormal(0.3, 2),randNormal2 = randomNormal(0.5, 1.:sunglasses:;const initialState = {particles: [],particleIndex: 0,particlesPerTick: 30,svgWidth: 800,svgHeight: 600,isTickerStarted: false,generateParticles: false,mousePos: [null, null],lastFrameTime: null};
Using D3's randomNormal
random number generator creates a better random
distribution than using JavaScript's own Math.random
. The rest is a bunch of
default state π
particles
holds an array of particles to drawparticleIndex
defines the ID of the next generated particleparticlesPerTick
defines how many particles we create on each requestAnimationFramesvgWidth
is the width of our drawing areasvgHeigh
is the heightisTickerStarted
specifies whether the animation is runninggenerateParticles
turns particle generation on and offmousePos
defines the origination point for new particleslastFrameTime
helps us compensate for dropped frames
To manipulate all this state, we use two reducers and manually combine them.
Redux does come with a combineReducers
function, but I wanted to keep our
state flat and that doesn't fit combineReducers
's view of how life should
work.
// src/reducers/index.js// Manually combineReducersexport default function (state = initialState, action) {return {...appReducer(state, action),...particlesReducer(state, action),}}
This is our reducer. It takes current state
, sets it to initialState
if
undefined, and an action. To create new state, it spreads the object returned
from appReducer
and from particlesReducer
into a new object. You can
combine as many reducers as you want in this way.
The usual combineReducers
approach leads to nested hierarchical state. That
often works great, but I wanted to keep our state flat.
Lesson here is that there are no rules. You can make your reducers whatever you want. Combine them whichever way fits your use case. As long as you take a state object and an action and return a new state object.
appReducer
will handle the constants and booleans and drive the metadata for
our animation. particlesReducer
will do the hard work of generating and
animating particles.
Driving the basics with appReducer
Our appReducer
handles the boring actions with a big switch statement. These
are common in the Redux world. They help us decide what to do based on action
type.
// src/reducers/index.jsfunction appReducer(state, action) {switch (action.type) {case "TICKER_STARTED":return Object.assign({}, state, {isTickerStarted: true,lastFrameTime: new Date(),})case "START_PARTICLES":return Object.assign({}, state, {generateParticles: true,})case "STOP_PARTICLES":return Object.assign({}, state, {generateParticles: false,})case "UPDATE_MOUSE_POS":return Object.assign({}, state, {mousePos: [action.x, action.y],})case "RESIZE_SCREEN":return Object.assign({}, state, {svgWidth: action.width,svgHeight: action.height,})default:return state}}
Gotta love that boilerplate :stuck_out_tongue:
Even though we're only changing values of boolean flags and two-digit arrays, we have to create a new state. Redux relies on application state being immutable.
Well, JavaScript doesn't have real immutability. We pretend and make sure to never change state without making a new copy first. There are libraries that give you proper immutable data structures, but that's a whole different course.
We use Object.assign({}, ...
to create a new empty object, fill it with the
current state, then overwrite specific values with new ones. This is fast
enough even with large state trees thanks to modern JavaScript engines.
Note that when a reducer doesn't recognize an action, it has to return the same state it received. Otherwise you end up wiping state. π
So that's the boilerplatey state updates. Manages starting and stopping the animation, flipping the particle generation switch, and resizing our viewport.
The fun stuff happens in particleReducer
.
Driving particles with particleReducer
Our particles live in an array. Each particle has an id, a position, and a vector. That tells us where to draw the particle and how to move it to its future position.
On each tick of the animation we have to:
- Generate new particles
- Remove particles outside the viewport
- Move every particle by its vector
We can do all that in one big reducer, like this:
// src/reducers/index.jsfunction particlesReducer(state, action) {switch (action.type) {case "TIME_TICK":let {svgWidth,svgHeight,lastFrameTime,generateParticles,particlesPerTick,particleIndex,mousePos,} = state,newFrameTime = new Date(),multiplier = (newFrameTime - lastFrameTime) / (1000 / 60),newParticles = state.particles.slice(0)if (generateParticles) {for (let i = 0; i < particlesPerTick; i++) {let particle = {id: state.particleIndex + i,x: mousePos[0],y: mousePos[1],}particle.vector = [particle.id % 2 ? -randNormal() : randNormal(),-randNormal2() * 3.3,]newParticles.unshift(particle)}particleIndex = particleIndex + particlesPerTick + 1}let movedParticles = newParticles.filter((p) => {return !(p.y > svgHeight || p.x < 0 || p.x > svgWidth)}).map((p) => {let [vx, vy] = p.vectorp.x += vx * multiplierp.y += vy * multiplierp.vector[1] += Gravity * multiplierreturn p})return {particles: movedParticles,lastFrameTime: new Date(),particleIndex,}default:return {particles: state.particles,lastFrameTime: state.lastFrameTime,particleIndex: state.particleIndex,}}}
That's a lot of code, I know. Let me explain π
The first part takes important values out of state
, calculates the dropped
frame multiplier, and makes a new copy of the particles array with .slice(0)
.
That was the fastest way I could find.
Then we generate new particles.
We loop through particlesPerTick
particles, create them at mousePos
coordinates, and insert at the beginning of the array. In my tests that
performed best. Particles get random movement vectors.
This randomness is a Redux faux pas. Reducers are supposed to be functionally pure: produce the same result every time they are called with the same argument values. Randomness is impure.
We don't need our particle vectors to be deterministic, so I think this is fine. Let's say our universe is stochastic instead π
{aside} Stochastic means that our universe/physic simulation is governed by probabilities. You can still model such a universe and reason about its behavior. A lot of real world physics is stochastic in nature. {/aside}
We now have an array full of old and new particles. We remove all out-of-bounds
particles with a filter
, then walk through what's left to move each particle
by its vector.
To simulate gravity, we update vectors' vertical component using our Gravity
constant. That makes particles fall down faster and faster creating a nice
parabola.
Our reducer is done. Our particle generator works. Our thing animates smoothly. \o/
What we learned
Building a particle generator in React and Redux, we made three important discoveries:
- Redux is much faster than you'd think. Creating a new copy of the state tree on each animation loop sounds crazy, but it works. Most of our code creates shallow copies, which explains the speed.
- Adding to JavaScript arrays is slow. Once we hit about 300 particles, adding new ones becomes slow. Stop adding particles and you get smooth animation. This indicates that something about creating particles is slow: either adding to the array, or creating React component instances, or creating SVG DOM nodes.
- SVG is also slow. To test the above hypothesis, I made the generator create 3000 particles on first click. The animation speed is terrible at first and becomes okayish at around 1000 particles. This suggests that making shallow copies of big arrays and moving existing SVG nodes around is faster than adding new DOM nodes and array elements. Here's a gif
There you go: Animating with React, Redux, and D3. Kind of a new superpower π
Here's the recap:
- React handles rendering
- D3 calculates stuff, detects mouse positions
- Redux handles state
- element coordinates are state
- change coordinates on every
requestAnimationFrame
- animation!
Now let's render to canvas and push this sucker to 20,000 smoothly animated elements. Even on a mobile phone.