Add user controls for data slicing and dicing
Now comes the fun part. All that extra effort we put into making our components aware of filtering, and it all comes down to this: User controls.
Here's what we're building:
It's a set of filters for users to slice and dice our visualization. The shortened dataset gives you 2 years, 12 job titles, and 50 US states. You'll get 5+ years and many more job titles with the full dataset.
We're using the
architecture we discussed
earlier to make it work. Clicking buttons updates a filter function and
communicates it all the way up to the App
component. App
then uses it to
update this.state.filteredSalaries
, which triggers a re-render and updates
our dataviz.
We're building controls in 4 steps, top to bottom:
- Update
App.js
with filtering and a<Controls>
render - Build a
Controls
component, which builds the filter based on inputs - Build a
ControlRow
component, which handles a row of buttons - Build a
Toggle
component, which is a button
We'll go through the files linearly. That makes them easier for me to explain and easier for you to understand, but that also means there's going to be a long period where all you're seeing is an error like this:
If you want to see what's up during this process, remove an import or two and
maybe a thing from render. For instance, it's complaining about ControlRow
in
this screenshot. Remove the ControlRow
import on top and delete
<ControlRow ... />
from render. The error goes away, and you see what you're
doing.
Step 1: Update App.js
All right, you know the drill. Add imports, tweak some things, add to render.
We have to import Controls
, set up filtering, update the map's zoom
prop,
and render a white rectangle and Controls
.
The white rectangle makes it so the zoomed-in map doesn't cover up the histogram. I'll explain when we get there.
// src/App.jsimport MedianLine from "./components/MedianLine"// Insert the line(s) between here...import Controls from "./components/Controls"// ...and here.class App extends React.Component {// Insert the line(s) between here...const [salariesFilter, setSalariesFilter] = useState(() => () => true);// ...and here.const [filteredBy, setFilteredBy] = useState({USstate: "*",year: "*",jobTitle: "*",});// ...// Insert the line(s) between here...function updateDataFilter(filter, filteredBy) {setFilteredBy(filteredBy);setSalariesFilter(() => filter);}// ...and here.render() {// ...}}
We import the Controls
component and add a default salariesFilter
function
to this.state
. The updateDataFilter
method passes the filter function and
filteredBy
dictionary from arguments to App state. We'll use it as a callback
in Controls
.
The rest of filtering setup happens in the render method.
// src/App.jsfunction App() {// ...// ...// Delete the line(s) between here...const filteredSalaries = techSalaries// ...and here.// Insert the line(s) between here...const filteredSalaries = techSalaries.filter(salariesFilter)// ...and here.// ...let zoom = null,medianHousehold = // ...// Insert the line(s) between here...if (filteredBy.USstate !== "*") {zoom = filteredBy.USstate;medianHousehold = d3.mean(medianIncomesByUSState[zoom],(d) => d.medianIncome);}// ...and here.// ...}}
We add a .filter
call to filteredSalaries
, which uses our salariesFilter
method to throw out anything that doesn't fit. Then we set up zoom
, if a US
state was selected.
We built the CountyMap
component to focus on a given US state. Finding the
centroid of a polygon, re-centering the map, and increasing the sizing factor.
It creates a nice zoom effect.
And here's the downside of this approach. SVG doesn't know about element boundaries. It just renders stuff.
See, it goes under the histogram. Let's fix that and add the Controls
render
while we're at it.
// src/App.jsfunction App() {// ...// ...return (<div //...><svg //...><CountyMap //... />// Insert the line(s) between here...<rect x="500" y="0"width="600"height="500"style={{fill: 'white'}} />// ...and here.<Histogram //... /><MedianLine //.. /></svg>// Insert the line(s) between here...<Controls data={techSalaries}updateDataFilter={updateDataFilter} />// ...and here.</div>)}}
Rectangle, 500
to the right, 0
from top, 600
wide and 500
tall, with a
white background. Gives the histogram an opaque background, so it doesn't
matter what the map is doing.
We render the Controls
component just after </svg>
because it's not an SVG
component โ it uses normal HTML. Unlike other components, it needs our entire
dataset as data
. We use the updateDataFilter
prop to say which callback
function it should call when a new filter is ready.
If this seems roundabout ... I've seen worse. The callbacks approach makes our
app easier to componentize and keeps the code relatively unmessy. Imagine
putting everything we've done so far in App
! :satisfied:
Step 2: Build Controls component
The Controls
component builds our filter function and filteredBy
dictionary
based on user choices.
Controls
renders 3 rows of buttons and builds filtering out of the choice
made on each row. That makes Controls
kind of repetitive, but that's okay.
To keep this book shorter, we're going to build everything for a year
filter
first. Then I'll explain how to add USstate
and jobTitle
filters on a
higher level. Once you have one working, the rest follows that same pattern.
Make a Controls
directory in src/components/
and let's begin. The main
Controls
component goes in your index.js
file.
Stub Controls
// src/components/Controls.jsimport React from "react"import ControlRow from "./ControlRow"const Controls = ({ data, updateDataFilter }) => {const [filteredBy, setFilteredBy] = useState({year: "*",})const [filterFunctions, setFilter] = useState({year: () => true,})const updateYearFilter = (year, reset) => {}}export default Controls
We start with some imports and a Controls
class-based component. Inside, we
define default state
with an always-true yearFilter
and an asterisk for
year
.
We also need an updateYearFilter
function, which we'll use to update the
filter, a reportUpdateUpTheChain
function, and a render
method. We're using
reportUpdateUpTheChain
to bubble updates to our parent component. It's a
simpler alternative to using React Context or a state management library.
Filter logic
// src/components/Controls.jsconst Controls = (...) => {// ...const updateYearFilter = (year, reset) => {let yearFilter = (d) => d.submit_date.getFullYear() === yearif (reset || !year) {yearFilter = () => trueyear = "*"}setFilteredBy((filteredBy) => {return { ...filteredBy, year }})setFilter((filterFunctions) => {return { ...filterFunctions, year: yearFilter }})}}
updateYearFilter
is a callback we pass into ControlRow
. When a user picks a
year, their action triggers this function.
When that happens, we create a new partial filter function. The App
component
uses it inside a .filter
call like you saw earlier. We have to return true
for elements we want to keep and false
for elements we don't.
Comparing submit_date.getFullYear()
with year
achieves that.
The reset
argument lets us reset filters back to defaults. Enables users to
unselect options.
When we have the year
and filter
, we update component state with
this.setState
. This triggers a re-render and calls reportUpdateUpTheChain
afterwards. Great use-case for the little known setState callback ๐
reportUpdateUpTheChain
then calls this.props.updateDataFilter
, which is a
callback method on App
. We defined it earlier โ it needs a new filter method
and a filteredBy
dictionary.
// src/components/Controls.jsconst Controls = ( ... ) => {// ...function reportUpdateUpTheChain() {window.location.hash = [filteredBy.year].join("-");const filter = (d) =>filterFunctions.year(d)updateDataFilter(filter, filteredBy);}}
Building the filter method looks tricky because we're composing multiple
functions. The new arrow function takes a dictionary of filters as an argument
and returns a new function that &&
s them all. We invoke it immediately with
this.state
as the argument.
It looks silly when there's just one filter, but I promise it makes sense.
Render
Great, we have the filter logic. Let's render those rows of controls we've been talking about.
// src/components/Controls/index.jsconst Controls = (...) => {// ...const years = new Set(data.map((d) => d.submit_date.getFullYear()));return (<div><ControlRowdata={data}toggleNames={Array.from(years.values())}picked={filteredBy.year}updateDataFilter={updateYearFilter}/></div>)}
Once more, this is generalized code used for a single example: the year
filter.
We build a Set
of distinct years in our dataset, then render a ControlRow
using props to give it our data
, a set of toggleNames
, a callback to update
the filter, and which entry is picked
right now. Also known as the controlled
component pattern, it enables us to maintain the data-flows-down,
events-bubble-up architecture.
If you don't know about Set
s, they're an ES6 data structure that ensures
every entry is unique. Just like a mathematical set. They're pretty fast.
Step 3: Build ControlRow component
Let's build the ControlRow
component. It renders a row of controls and
ensures only one at a time is selected.
We'll start with a stub and go from there.
// src/components/Controls.jsimport React from "react"const ControlRow = ({data,toggleNames,picked,updateDataFilter,capitalize,}) => {function makePick(picked, newState) {updateDataFilter(picked, !newState)}}
makePick
calls the data filter update and passes in the new value and whether
we want to unselect. Pretty simple right?
// src/components/Controls.jsconst ControlRow = () => {// ...return (<div className="row"><div className="col-md-12">{toggleNames.map((name) => (<Togglelabel={capitalize ? name.toUpperCase() : name}name={name}key={name}value={picked === name}onClick={makePick}/>))}</div></div>)}
In render, we set up two div
s with Bootstrap classes. The first is a row
,
and the second is a full-width column. I tried using a column for each button,
but it was annoying to manage and used too much space.
Inside the divs, we map over all toggles and use the <Toggle>
component to render each of them. The label is a prettier version of the name, which
also serves as a key in our toggleValues
dictionary. It's going to be the
picked
attribute in makePick
.
Your browser should continue showing an error, but it should change to talking
about the Toggle
component instead of ControlRow
.
Let's build it.
Step 5: Add US state and Job Title filters
With all that done, we can add two more filters: US states and job titles. Our
App
component is already set up to use them, so we just have to add them to
the Controls
component.
We'll start with the render
method, then handle the parts I said earlier
would look repetitive.
// src/components/Controls.jsconst Controls = () => {// ...const years = new Set(data.map((d) => d.submit_date.getFullYear())),// Insert the line(s) between here...jobTitles = new Set(data.map((d) => d.clean_job_title)),USstates = new Set(data.map((d) => d.USstate))// ...and here.return (<div><ControlRowdata={data}toggleNames={Array.from(years.values())}picked={this.state.year}updateDataFilter={this.updateYearFilter}/>// Insert the line(s) between here...<ControlRowdata={data}toggleNames={Array.from(jobTitles.values())}picked={this.state.jobTitle}updateDataFilter={this.updateJobTitleFilter}/><ControlRowdata={data}toggleNames={Array.from(USstates.values())}picked={this.state.USstate}updateDataFilter={this.updateUSstateFilter}capitalize="true"/>// ...and here.</div>)}}
Ok, this part is plenty repetitive, too.
We created new sets for jobTitles
and USstates
, then rendered two more
ControlRow
elements with appropriate attributes. They get toggleNames
for
building the buttons, picked
to know which is active, an updateDataFilter
callback, and we tell US states to render capitalized.
The implementations of those updateDataFilter
callbacks follow the same
pattern as updateYearFilter
.
// src/components/Controls.jsconst Controls = () => {const [filteredBy, setFilteredBy] = useState({year: "*",USstate: "*",jobTitle: "*",})const [filterFunctions, setFilter] = useState({year: () => true,USstate: () => true,jobTitle: () => true,})const updateJobTitleFilter = (jobTitle, reset) => {let jobTitleFilter = (d) => d.clean_job_title === jobTitleif (reset || !jobTitle) {jobTitleFilter = () => truejobTitle = "*"}setFilteredBy((filteredBy) => {return { ...filteredBy, jobTitle }})setFilter((filterFunctions) => {return { ...filterFunctions, jobTitle: jobTitleFilter }})reportUpdateUpTheChain()}const updateUSstateFilter = (USstate, reset) => {let USstateFilter = (d) => d.clean_job_title === USstateif (reset || !USstate) {USstateFilter = () => trueUSstate = "*"}setFilteredBy((filteredBy) => {return { ...filteredBy, USstate }})setFilter((filterFunctions) => {return { ...filterFunctions, USstate: USstateFilter }})reportUpdateUpTheChain()}// ..}export default Controls
Yes, they're basically the same as updateYearFilter
. The only difference is a
changed filter
function and using different keys when setting state.
Why separate functions then? No need to get fancy. It would've made the code harder to read.
Our last step is to add these new keys to the reportUpdateUpTheChain
function.
// src/components/Controls/index.jsconst Controls = () => {function reportUpdateUpTheChain() {window.location.hash = [filteredBy.year,filteredBy.USstate,filteredBy.jobTitle,].join("-")const filter = (d) =>filterFunctions.year(d) &&filterFunctions.USstate(d) &&filterFunctions.jobTitle(d)updateDataFilter(filter, filteredBy)}}
We add them to the filter condition with &&
and expand the filteredBy
argument.
Two more rows of filters show up.
๐
Stale state is stale ๐คจ
A funny thing happened. We were calling reportUpdateUpTheChain
which relies on local state in the component.
But that state doesn't update right away. And the state hook provides no callback like the old this.setState
used to.
reportUpdateUpTheChain
is a side-effect! We have to use useEffect
.
// src/components/Controls.jsconst Controls = ({ data, updateDataFilter }) => {useEffect(() => {reportUpdateUpTheChain()}, [filteredBy, filterFunctions])}
You should now have a working data visualization dashboard with user controls. It struggles on certain clicks because of the shortened dataset.
If that didn't work, consult the diff on GitHub
Next up - going live ๐