Chapter 4. Interaction Design
In this chapter, we look at some recipes that address a bunch of typical interface problems. How do you deal with errors? How do you help people use your system? How do you create complex input sequences without writing a bunch of spaghetti code?
This is a collection of tips that we’ve found useful, time and again. At the end of the chapter, we look at various ways of adding animation to your application. We take a low-tech approach where possible, and ideally, the recipes we include will add meaning to your interface designs with a minimum of fuss.
4.1 Build a Centralized Error Handler
Problem
It’s hard to define precisely what makes good software good. But one thing that most excellent software has in common is how it responds to errors and exceptions. There will always be exceptional, unexpected situations when people are running your code: the network can disappear, the server can crash, the storage can become corrupted. It’s important to consider how you should deal with these situations when they occur.
One approach that is almost certain to fail is to ignore the fact that error conditions occur and to hide the gory details of what went wrong. Somewhere, somehow, you need to leave a trail of evidence that you can use to prevent that error from happening again.
When we’re writing server code, we might log the error details and return an appropriate message to a request. But if we’re writing client code, we need a plan for how we’ll deal with local errors. We might choose to display the crash’s details to the user and ask them to file an error report. We might use a third-party service like Sentry.io to log the details remotely.
Whatever our code does, it should be consistent. But how can we handle exceptions consistently in a React application?
Solution
In this recipe, we’re going to look at one way of creating a centralized error handler. To be clear: this code won’t automatically
capture all exceptions. It still needs to be added explicitly to JavaScript catch
blocks. It’s also not a replacement for dealing
with any error from which we can otherwise recover. If an order fails because the server is down for maintenance, it is much better
to ask the user to try again later.
But this technique helps catch any errors for which we have not previously planned.
As a general principle, when something goes wrong, there are three things that you should tell the user:
-
What happened
-
Why it happened
-
What they should do about it
In the example we show here, we’re going to handle errors by displaying a dialog box that shows the details of a JavaScript Error
object and asks the user to email the contents to systems support. We want a simple error-handler function that we can call when an
error happens:
setVisibleError
(
'Cannot do that thing'
,
errorObject
)
If we want to make the function readily available across the entire application, the usual way is by using a context. A context is a kind of scope that we can wrap around a set of React components. Anything we put into that context is available to all the child components. We will use our context to store the error-handler function that we can run when an error occurs.
We’ll call our context ErrorHandlerContext
:
import
React
from
'react'
const
ErrorHandlerContext
=
React
.
createContext
(()
=>
{})
export
default
ErrorHandlerContext
To allow us to make the context available to a set of components, let’s create an
ErrorHandlerProvider
component that will create
an instance of the context and make it available to any child components we pass to it:
import
ErrorHandlerContext
from
'./ErrorHandlerContext'
let
setError
=
()
=>
{}
const
ErrorHandlerProvider
=
(
props
)
=>
{
if
(
props
.
callback
)
{
setError
=
props
.
callback
}
return
(
<
ErrorHandlerContext
.
Provider
value
=
{
setError
}>
{
props
.
children
}
</
ErrorHandlerContext
.
Provider
>
)
}
export
default
ErrorHandlerProvider
Now we need some code that says what to do when we call the error-handler function. In our case, we need some code that will respond to an error report by displaying a dialog box containing all of the error details. If you want to handle errors differently, this is the code you need to modify:
import
{
useCallback
,
useState
}
from
'react'
import
ErrorHandlerProvider
from
'./ErrorHandlerProvider'
import
ErrorDialog
from
'./ErrorDialog'
const
ErrorContainer
=
(
props
)
=>
{
const
[
error
,
setError
]
=
useState
()
const
[
errorTitle
,
setErrorTitle
]
=
useState
()
const
[
action
,
setAction
]
=
useState
()
if
(
error
)
{
console
.
error
(
'An error has been thrown'
,
errorTitle
,
JSON
.
stringify
(
error
)
)
}
const
callback
=
useCallback
((
title
,
err
,
action
)
=>
{
console
.
error
(
'ERROR RAISED '
)
console
.
error
(
'Error title: '
,
title
)
console
.
error
(
'Error content'
,
JSON
.
stringify
(
err
))
setError
(
err
)
setErrorTitle
(
title
)
setAction
(
action
)
},
[])
return
(
<
ErrorHandlerProvider
callback
=
{
callback
}>
{
props
.
children
}
{
error
&&
(
<
ErrorDialog
title
=
{
errorTitle
}
onClose
=
{()
=>
{
setError
(
null
)
setErrorTitle
(
''
)
}}
action
=
{
action
}
error
=
{
error
}
/>
)}
</
ErrorHandlerProvider
>
)
}
export
default
ErrorContainer
The ErrorContainer
displays the details using an ErrorDialog
. We won’t go into the details of the code for ErrorDialog
here as this is the code that you are most likely to replace with your implementation.1
We need to wrap the bulk of our application in an ErrorContainer
. Any components inside the ErrorContainer
will be able to call
the error handler:
import
'./App.css'
import
ErrorContainer
from
'./ErrorContainer'
import
ClockIn
from
'./ClockIn'
function
App
()
{
return
(
<
div
className
=
"App"
>
<
ErrorContainer
>
<
ClockIn
/>
</
ErrorContainer
>
</
div
>
)
}
export
default
App
How does a component use the error handler? We’ll create a custom hook called useErrorHandler()
, which will get the error-handler
function out of the context and return it:
import
ErrorHandlerContext
from
'./ErrorHandlerContext'
import
{
useContext
}
from
'react'
const
useErrorHandler
=
()
=>
useContext
(
ErrorHandlerContext
)
export
default
useErrorHandler
That’s quite a complex set of code, but now we come to use the error handler; it’s very simple. This example code makes a network request when a user clicks a button. If the network request fails, then the details of the error are passed to the error handler:
import
useErrorHandler
from
'./useErrorHandler'
import
axios
from
'axios'
const
ClockIn
=
()
=>
{
const
setVisibleError
=
useErrorHandler
()
const
doClockIn
=
async
()
=>
{
try
{
await
axios
.
put
(
'/clockTime'
)
}
catch
(
err
)
{
setVisibleError
(
'Unable to record work start time'
,
err
)
}
}
return
(
<>
<
h1
>
Click
Button
to
Record
Start
Time
</
h1
>
<
button
onClick
=
{
doClockIn
}>
Start
work
</
button
>
</>
)
}
export
default
ClockIn
You can see what the app looks like in Figure 4-1.
When you click the button, the network request fails because the server code doesn’t exist. Figure 4-2 shows the error dialog that appears. Notice that it shows what went wrong, why it went wrong, and what the user should do about it.
Discussion
Of all the recipes that we’ve created over the years, this one has saved the most time. During development, code often breaks, and if the only evidence of a failure is a stack trace hidden away inside the JavaScript console, you are likely to miss it.
Significantly, when some piece of infrastructure (networks, gateways, servers, databases) fails, this small amount of code can save you untold hours tracking down the cause.
You can download the source for this recipe from the GitHub site.
4.2 Create an Interactive Help Guide
Problem
Tim Berners-Lee deliberately designed the web to have very few features. It has a simple protocol (HTTP), and it originally had a straightforward markup language (HTML). The lack of complexity meant that new users of websites immediately knew how to use them. If you saw something that looked like a hyperlink, you could click on it and go to another page.
But rich JavaScript applications have changed all that. No longer are web applications a collection of hyperlinked web pages. Instead, they resemble old desktop applications; they are more powerful and feature-rich, but the downside is that they are now far more complex to use.
How do you build an interactive guide into your application?
Solution
We’re going to build a simple help system that you can overlay onto an existing application. When the user opens the help, they will see a series of pop-up notes that describe how to use the various features they can see on the page, as shown in Figure 4-3.
We want something that will be easy to maintain and will provide help only for visible components. That sounds like quite a big task, so let’s begin by first constructing a component that will display a pop-up help message:
import
{
Popper
}
from
'@material-ui/core'
import
'./HelpBubble.css'
const
HelpBubble
=
(
props
)
=>
{
const
element
=
props
.
forElement
?
document
.
querySelector
(
props
.
forElement
)
:
null
return
element
?
(
<
Popper
className
=
"HelpBubble-container"
open
=
{
props
.
open
}
anchorEl
=
{
element
}
placement
=
{
props
.
placement
||
'bottom-start'
}
>
<
div
className
=
"HelpBubble-close"
onClick
=
{
props
.
onClose
}>
Close
[
X
]
</
div
>
{
props
.
content
}
<
div
className
=
"HelpBubble-controls"
>
{
props
.
previousLabel
?
(
<
div
className
=
"HelpBubble-control HelpBubble-previous"
onClick
=
{
props
.
onPrevious
}
>
&
lt
;
{
props
.
previousLabel
}
</
div
>
)
:
(
<
div
>
&
nbsp
;</
div
>
)}
{
props
.
nextLabel
?
(
<
div
className
=
"HelpBubble-control HelpBubble-next"
onClick
=
{
props
.
onNext
}
>
{
props
.
nextLabel
}
&
gt
;
</
div
>
)
:
(
<
div
>
&
nbsp
;</
div
>
)}
</
div
>
</
Popper
>
)
:
null
}
export
default
HelpBubble
We’re using the Popper
component from the @material-ui
library. The Popper
component can be anchored on the page, next to
some other component. Our HelpBubble
takes a forElement
string, which will represent a CSS selector such as
.class-name
or #some-id
. We will use selectors to associate things on the screen with pop-up messages.
Now that we have a pop-up message component, we’ll need something that coordinates a sequence of HelpBubbles
. We’ll call this the
HelpSequence
:
import
{
useEffect
,
useState
}
from
'react'
import
HelpBubble
from
'./HelpBubble'
function
isVisible
(
e
)
{
return
!!
(
e
.
offsetWidth
||
e
.
offsetHeight
||
e
.
getClientRects
().
length
)
}
const
HelpSequence
=
(
props
)
=>
{
const
[
position
,
setPosition
]
=
useState
(
0
)
const
[
sequence
,
setSequence
]
=
useState
()
useEffect
(()
=>
{
if
(
props
.
sequence
)
{
const
filter
=
props
.
sequence
.
filter
((
i
)
=>
{
if
(
!
i
.
forElement
)
{
return
false
}
const
element
=
document
.
querySelector
(
i
.
forElement
)
if
(
!
element
)
{
return
false
}
return
isVisible
(
element
)
})
setSequence
(
filter
)
}
else
{
setSequence
(
null
)
}
},
[
props
.
sequence
,
props
.
open
])
const
data
=
sequence
&&
sequence
[
position
]
useEffect
(()
=>
{
setPosition
(
0
)
},
[
props
.
open
])
const
onNext
=
()
=>
setPosition
((
p
)
=>
{
if
(
p
===
sequence
.
length
-
1
)
{
props
.
onClose
&&
props
.
onClose
()
}
return
p
+
1
})
const
onPrevious
=
()
=>
setPosition
((
p
)
=>
{
if
(
p
===
0
)
{
props
.
onClose
&&
props
.
onClose
()
}
return
p
-
1
})
return
(
<
div
className
=
"HelpSequence-container"
>
{
data
&&
(
<
HelpBubble
open
=
{
props
.
open
}
forElement
=
{
data
.
forElement
}
placement
=
{
data
.
placement
}
onClose
=
{
props
.
onClose
}
previousLabel
=
{
position
>
0
&&
'Previous'
}
nextLabel
=
{
position
<
sequence
.
length
-
1
?
'Next'
:
'Finish'
}
onPrevious
=
{
onPrevious
}
onNext
=
{
onNext
}
content
=
{
data
.
text
}
/>
)}
</
div
>
)
}
export
default
HelpSequence
The HelpSequence
takes an array of JavaScript objects like this:
[
{
forElement
:
"p"
,
text
:
"This is some introductory text telling you how to start"
},
{
forElement
:
".App-link"
,
text
:
"This will show you how to use React"
},
{
forElement
:
".App-nowhere"
,
text
:
"This help text will never appear"
},
]
and converts it into a dynamic sequence of HelpBubbles
. It will show a HelpBubble
only if it can find an element that matches
the forElement
selector. It then places the HelpBubble
next to the element and shows the help text.
Let’s add a HelpSequence
to the default App.js code generated by create-react-app
:
import
{
useState
}
from
'react'
import
logo
from
'./logo.svg'
import
HelpSequence
from
'./HelpSequence'
import
'./App.css'
function
App
()
{
const
[
showHelp
,
setShowHelp
]
=
useState
(
false
)
return
(
<
div
className
=
"App"
>
<
header
className
=
"App-header"
>
<
img
src
=
{
logo
}
className
=
"App-logo"
alt
=
"logo"
/>
<
p
>
Edit
<
code
>
src
/
App
.
js
</
code
>
and
save
to
reload
.
</
p
>
<
a
className
=
"App-link"
href
=
"https://reactjs.org"
target
=
"_blank"
rel
=
"noopener noreferrer"
>
Learn
React
</
a
>
</
header
>
<
button
onClick
=
{()
=>
setShowHelp
(
true
)}>
Show
help
</
button
>
<
HelpSequence
sequence
=
{[
{
forElement
:
'p'
,
text
:
'This is some introductory text telling you how to start'
,
},
{
forElement
:
'.App-link'
,
text
:
'This will show you how to use React'
,
},
{
forElement
:
'.App-nowhere'
,
text
:
'This help text will never appear'
,
},
]}
open
=
{
showHelp
}
onClose
=
{()
=>
setShowHelp
(
false
)}
/>
</
div
>
)
}
export
default
App
To begin with, we cannot see anything different other than a help button (see Figure 4-4).
When the user clicks the help button, the first help topic appears, as shown in Figure 4-5.
Figure 4-6 shows the help moving to the next element when the user clicks Next. The user can continue to move from item to item until there are no more matching elements visible.
Discussion
Adding interactive help to your application makes your user interface discoverable. Developers spend a lot of their time adding functionality to applications that people might never use, simply because they don’t know that it’s there.
The implementation in this recipe displays the help as simple plain text. You might consider using Markdown, as that will allow for a richer experience, and help topics can then include links to other more expansive help pages.2
The help topics are automatically limited to just those elements that are visible on the page. You could choose to create either a separate help sequence for each page or a single large help sequence that will automatically adapt to the user’s current view of the interface.
Finally, a help system like this is ideally suited for storage in a headless CMS, which will allow you to update help dynamically, without the need to create a new deployment each time.
You can download the source for this recipe from the GitHub site.
4.3 Use Reducers for Complex Interactions
Problem
Applications frequently need users to follow a sequence of actions. They might be completing the steps in a wizard, or they might need to log in and confirm some dangerous operation (see Figure 4-7).
Not only will the user need to perform a sequence of steps, but the steps might be conditional. If the user has logged in recently, they perhaps don’t need to log in again. They might want to cancel partway through the sequence.
If you model the complex sequences inside your components, you can soon find your application is full of spaghetti code.
Solution
We are going to use a reducer to manage a complex sequence of operations. We introduced reducers for managing state in Chapter 3. A reducer is a function that accepts a state object and an action. The reducer uses the action to decide how to change the state, and it must have no side effects.
Because reducers have no user-interface code, they are perfect for managing gnarly pieces of interrelated state without worrying about the visual appearance. They are particularly amenable to unit testing.
For example, let’s say we implement the deletion sequence mentioned at the start of this recipe. We can begin in classic test-driven style by writing a unit test:
import
deletionReducer
from
'./deletionReducer'
describe
(
'deletionReducer'
,
()
=>
{
it
(
'should show the login dialog if we are not logged in'
,
()
=>
{
const
actual
=
deletionReducer
({},
{
type
:
'START_DELETION'
})
expect
(
actual
.
showLogin
).
toBe
(
true
)
expect
(
actual
.
message
).
toBe
(
''
)
expect
(
actual
.
deleteButtonDisabled
).
toBe
(
true
)
expect
(
actual
.
loginError
).
toBe
(
''
)
expect
(
actual
.
showConfirmation
).
toBe
(
false
)
})
})
Here our reducer function is going to be called deletionReducer
. We pass it an empty object ({}
) and an action that says we want
to start the deletion process ({type: 'START_DELETION'}
). We then say that we expect the new version of the state to have a
showLogin
value of true
, a showConfirmation
value of false
, and so on.
We can then implement the code for a reducer to do just that:
function
deletionReducer
(
state
,
action
)
{
switch
(
action
.
type
)
{
case
'START_DELETION'
:
return
{
...
state
,
showLogin
:
true
,
message
:
''
,
deleteButtonDisabled
:
true
,
loginError
:
''
,
showConfirmation
:
false
,
}
default
:
return
null
// Or anything
}
}
At first, we are merely setting the state attributes to values that pass the test. As we add more and more tests, our reducer improves as it handles more situations.
Eventually, we get something that looks like this:3
function
deletionReducer
(
state
,
action
)
{
switch
(
action
.
type
)
{
case
'START_DELETION'
:
return
{
...
state
,
showLogin
:
!
state
.
loggedIn
,
message
:
''
,
deleteButtonDisabled
:
true
,
loginError
:
''
,
showConfirmation
:
!!
state
.
loggedIn
,
}
case
'CANCEL_DELETION'
:
return
{
...
state
,
showLogin
:
false
,
showConfirmation
:
false
,
showResult
:
false
,
message
:
'Deletion canceled'
,
deleteButtonDisabled
:
false
,
}
case
'LOGIN'
:
const
passwordCorrect
=
action
.
payload
===
'swordfish'
return
{
...
state
,
showLogin
:
!
passwordCorrect
,
showConfirmation
:
passwordCorrect
,
loginError
:
passwordCorrect
?
''
:
'Invalid password'
,
loggedIn
:
true
,
}
case
'CONFIRM_DELETION'
:
return
{
...
state
,
showConfirmation
:
false
,
showResult
:
true
,
message
:
'Widget deleted'
,
}
case
'FINISH'
:
return
{
...
state
,
showLogin
:
false
,
showConfirmation
:
false
,
showResult
:
false
,
deleteButtonDisabled
:
false
,
}
default
:
throw
new
Error
(
'Unknown action: '
+
action
.
type
)
}
}
export
default
deletionReducer
Although this code is complicated, you can write it quickly if you create the tests first.
Now that we have the reducer, we can use it in our application:
import
{
useReducer
,
useState
}
from
'react'
import
'./App.css'
import
deletionReducer
from
'./deletionReducer'
function
App
()
{
const
[
state
,
dispatch
]
=
useReducer
(
deletionReducer
,
{})
const
[
password
,
setPassword
]
=
useState
()
return
(
<
div
className
=
"App"
>
<
button
onClick
=
{()
=>
{
dispatch
({
type
:
'START_DELETION'
})
}}
disabled
=
{
state
.
deleteButtonDisabled
}
>
Delete
Widget
!
</
button
>
<
div
className
=
"App-message"
>{
state
.
message
}</
div
>
{
state
.
showLogin
&&
(
<
div
className
=
"App-dialog"
>
<
p
>
Enter
your
password
</
p
>
<
input
type
=
"password"
value
=
{
password
}
onChange
=
{(
evt
)
=>
setPassword
(
evt
.
target
.
value
)}
/>
<
button
onClick
=
{()
=>
dispatch
({
type
:
'LOGIN'
,
payload
:
password
})
}
>
Login
</
button
>
<
button
onClick
=
{()
=>
dispatch
({
type
:
'CANCEL_DELETION'
})}
>
Cancel
</
button
>
<
div
className
=
"App-error"
>{
state
.
loginError
}</
div
>
</
div
>
)}
{
state
.
showConfirmation
&&
(
<
div
className
=
"App-dialog"
>
<
p
>
Are
you
sure
you
want
to
delete
the
widget
?
</
p
>
<
button
onClick
=
{()
=>
dispatch
({
type
:
'CONFIRM_DELETION'
,
})
}
>
Yes
</
button
>
<
button
onClick
=
{()
=>
dispatch
({
type
:
'CANCEL_DELETION'
,
})
}
>
No
</
button
>
</
div
>
)}
{
state
.
showResult
&&
(
<
div
className
=
"App-dialog"
>
<
p
>
The
widget
was
deleted
</
p
>
<
button
onClick
=
{()
=>
dispatch
({
type
:
'FINISH'
,
})
}
>
Done
</
button
>
</
div
>
)}
</
div
>
)
}
export
default
App
Most of this code is purely creating the user interface for each of the dialogs in the sequence. There is virtually no logic in this component. It just does what the reducer tells it. It will take the user through the happy path of logging in and confirming the deletion (see Figure 4-8).
But Figure 4-9 shows it also handles all of the edge cases, such as invalid passwords and cancellation.
Discussion
There are times when reducers can make your code convoluted; if you have few pieces of state with few interactions between them, you probably don’t need a reducer. But if you find yourself drawing a flowchart or a state diagram to describe a sequence of user interactions, that’s a sign that you might need a reducer.
You can download the source for this recipe from the GitHub site.
4.4 Add Keyboard Interaction
Solution
We’re going to create a key-listener hook to listen for keydown
events at the document
level. Still, it could be easily modified
to listen for any other JavaScript event in the DOM. This is the hook:
import
{
useEffect
}
from
'react'
const
useKeyListener
=
(
callback
)
=>
{
useEffect
(()
=>
{
const
listener
=
(
e
)
=>
{
e
=
e
||
window
.
event
const
tagName
=
e
.
target
.
localName
||
e
.
target
.
tagName
// Only accept key-events that originated at the body level
// to avoid key-strokes in e.g. text-fields being included
if
(
tagName
.
toUpperCase
()
===
'BODY'
)
{
callback
(
e
)
}
}
document
.
addEventListener
(
'keydown'
,
listener
,
true
)
return
()
=>
{
document
.
removeEventListener
(
'keydown'
,
listener
,
true
)
}
},
[
callback
])
}
export
default
useKeyListener
The hook accepts a callback function and registers it for keydown
events on the
document
object. At the end of the useEffect
,
it returns a function that will unregister the callback. If the callback function we pass in changes, we will first unregister the
old function before registering the new one.
How do we use the hook? Here is an example. See if you notice the little coding wrinkle we have to deal with:
import
{
useCallback
,
useState
}
from
'react'
import
'./App.css'
import
useKeyListener
from
'./useKeyListener'
const
RIGHT_ARROW
=
39
const
LEFT_ARROW
=
37
const
ESCAPE
=
27
function
App
()
{
const
[
angle
,
setAngle
]
=
useState
(
0
)
const
[
lastKey
,
setLastKey
]
=
useState
(
''
)
let
onKeyDown
=
useCallback
(
(
evt
)
=>
{
if
(
evt
.
keyCode
===
LEFT_ARROW
)
{
setAngle
((
c
)
=>
Math
.
max
(
-
360
,
c
-
10
))
setLastKey
(
'Left'
)
}
else
if
(
evt
.
keyCode
===
RIGHT_ARROW
)
{
setAngle
((
c
)
=>
Math
.
min
(
360
,
c
+
10
))
setLastKey
(
'Right'
)
}
else
if
(
evt
.
keyCode
===
ESCAPE
)
{
setAngle
(
0
)
setLastKey
(
'Escape'
)
}
},
[
setAngle
]
)
useKeyListener
(
onKeyDown
)
return
(
<
div
className
=
"App"
>
<
p
>
Angle
:
{
angle
}
Last
key
:
{
lastKey
}
</
p
>
<
svg
width
=
"400px"
height
=
"400px"
title
=
"arrow"
fill
=
"none"
strokeWidth
=
"10"
stroke
=
"black"
style
=
{{
transform
:
`rotate(
${
angle
}
deg)`
,
}}
>
<
polyline
points
=
"100,200 200,0 300,200"
/>
<
polyline
points
=
"200,0 200,400"
/>
</
svg
>
</
div
>
)
}
export
default
App
This code listens for the user pressing the left/right cursor keys. Our onKeyDown
function says what should happen when those key
clicks occur, but notice that we’ve wrapped it in a useCallback
. If we didn’t do that, the browser would re-create the
onKeyDown
function each time it rendered the App
component. The new function would do the same as the old onKeyDown
function,
but it would live in a different place in memory, and the useKeyListener
would keep unregistering and re-registering it.
Warning
If you forget to wrap your callback function in a useCallback
, it may result in a blizzard of render calls, which might
slow your application down.
By using useCallback
, we can ensure that we only create the function if setAngle
changes.
If you run the application, you will see an arrow on the screen. If you press the left/right cursor keys (Figure 4-10), you can rotate the image. If you press the Escape key, you can reset it to vertical.
Discussion
We are careful in the useKeyListener
function to only listen to events that originated at the body
level. If the user clicks the
arrow keys in a text field, the browser won’t send those events to your code.
You can download the source for this recipe from the GitHub site.
4.5 Use Markdown for Rich Content
Problem
If your application allows users to provide large blocks of text content, it would be helpful if that content could also include formatted text, links, and so forth. However, allowing users to pass in such horrors as raw HTML can lead to security flaws and untold misery for developers.
How do you allow users to post rich content without undermining the security of your application?
Solution
Markdown is an excellent way of allowing users to post rich content into your application safely. To see how to use Markdown in your application, let’s consider this simple application, which allows a user to post a timestamped series of messages into a list:
import
{
useState
}
from
'react'
import
'./Forum.css'
const
Forum
=
()
=>
{
const
[
text
,
setText
]
=
useState
(
''
)
const
[
messages
,
setMessages
]
=
useState
([])
return
(
<
section
className
=
"Forum"
>
<
textarea
cols
=
{
80
}
rows
=
{
20
}
value
=
{
text
}
onChange
=
{(
evt
)
=>
setText
(
evt
.
target
.
value
)}
/>
<
button
onClick
=
{()
=>
{
setMessages
((
msgs
)
=>
[
{
body
:
text
,
timestamp
:
new
Date
().
toISOString
(),
},
...
msgs
,
])
setText
(
''
)
}}
>
Post
</
button
>
{
messages
.
map
((
msg
)
=>
{
return
(
<
dl
>
<
dt
>{
msg
.
timestamp
}</
dt
>
<
dd
>{
msg
.
body
}</
dd
>
</
dl
>
)
})}
</
section
>
)
}
export
default
Forum
When you run the application (Figure 4-11), you see a large text area. When you post a plain-text message, the app preserves white space and line breaks.
If your application contains a text area, it’s worth considering allowing the user to enter Markdown content.
There are many, many Markdown libraries available, but most of them are wrappers for react-markdown
or a syntax highlighter like PrismJS or CodeMirror.
We’ll look at a library called react-md-editor
that adds extra features to react-markdown
and allows you to display Markdown and
edit it. We will begin by installing the library:
$
npminstall
@uiw/react-md-editor
We’ll now convert our plain-text area to a Markdown editor and convert the posted messages from Markdown to HTML:
import
{
useState
}
from
'react'
import
MDEditor
from
'@uiw/react-md-editor'
const
MarkdownForum
=
()
=>
{
const
[
text
,
setText
]
=
useState
(
''
)
const
[
messages
,
setMessages
]
=
useState
([])
return
(
<
section
className
=
"Forum"
>
<
MDEditor
height
=
{
300
}
value
=
{
text
}
onChange
=
{
setText
}
/>
<
button
onClick
=
{()
=>
{
setMessages
((
msgs
)
=>
[
{
body
:
text
,
timestamp
:
new
Date
().
toISOString
(),
},
...
msgs
,
])
setText
(
''
)
}}
>
Post
</
button
>
{
messages
.
map
((
msg
)
=>
{
return
(
<
dl
>
<
dt
>{
msg
.
timestamp
}</
dt
>
<
dd
>
<
MDEditor
.
Markdown
source
=
{
msg
.
body
}
/>
</
dd
>
</
dl
>
)
})}
</
section
>
)
}
export
default
MarkdownForum
Converting plain text to Markdown is a small change with a significant return. As you can see in Figure 4-12, the user can apply rich formatting to a message and choose to edit it full-screen before posting it.
Discussion
Adding Markdown to an application is quick and improves the user’s experience with minimal effort. For more details on Markdown, see John Gruber’s original guide.
You can download the source for this recipe from the GitHub site.
4.6 Animate with CSS Classes
Solution
Most of the animation you are ever likely to need in a React application will probably not require a third-party animation library. That’s because CSS animation now gives browsers the native ability to animate CSS properties with minimal effort. It takes very little code, and the animation is smooth because the graphics hardware will generate it. GPU animation uses less power, making it more appropriate for mobile devices.
Tip
If you are looking to add animation to your React application, begin with CSS animation before looking elsewhere.
How does CSS animation work? It uses a CSS property called transition
. Let’s say we want to create an expandable information
panel. When the user clicks the button, the panel opens smoothly. When they click it again, it closes smoothly, as shown in Figure 4-13.
We can create this effect using the CSS transition
property:
.InfoPanel-details
{
height
:
350px
;
transition
:
height
0.5s
;
}
This CSS specifies a height
, as well as a transition
property. This combination translates to “Whatever your current height,
animate to my preferred height during the next half-second.”
The animation will occur whenever the height
of the element changes, such as when an additional CSS rule becomes valid. For
example, if we have an extra CSS class-name with a different height, the transition property will animate the height change when an element switches to a different class:
.InfoPanel-details
{
height
:
350px
;
transition
:
height
0.5s
;
}
.InfoPanel-details.InfoPanel-details-closed
{
height
:
0
;
}
Tip
This class name structure is an example of block element modifier (BEM) naming. The block is the component (InfoPanel
), the
element is a thing inside the block (details
), and the modifier says something about the element’s current state (closed
).
The BEM convention reduces the chances of name clashes in your code.
If an InfoPanel-details
element suddenly acquires an additional .InfoPanel-details-closed
class, the height
will change from
350px
to 0
, and the transition
property will smoothly shrink the element. Conversely, if the component loses the
.InfoPanel-details-closed
class, the element will expand again.
That means that we can defer the hard work to CSS, and all we need to do in our React code is add or remove the class to an element:
import
{
useState
}
from
'react'
import
'./InfoPanel.css'
const
InfoPanel
=
({
title
,
children
})
=>
{
const
[
open
,
setOpen
]
=
useState
(
false
)
return
(
<
section
className
=
"InfoPanel"
>
<
h1
>
{
title
}
<
button
onClick
=
{()
=>
setOpen
((
v
)
=>
!
v
)}>
{
open
?
'^'
:
'v'
}
</
button
>
</
h1
>
<
div
className
=
{
`InfoPanel-details
${
open
?
''
:
'InfoPanel-details-closed'
}
`
}
>
{
children
}
</
div
>
</
section
>
)
}
export
default
InfoPanel
Discussion
We have frequently seen many projects bundle in third-party component libraries to use some small widget that expands or contracts its contents. As you can see, such animation is trivial to include.
You can download the source for this recipe from the GitHub site.
4.7 Animate with React Animation
Problem
CSS animations are very low-tech and will be appropriate for most animations that you are likely to need.
However, they require you to understand a lot about the various CSS properties and the effects of animating them. If you want to illustrate an item being deleted by it rapidly expanding and becoming transparent, how do you do that?
Libraries such as Animate.css contain a whole host of pre-canned CSS animations, but they often require more advanced CSS animation concepts like keyframes and are not particularly tuned for React. How can we add CSS library animations to a React application?
Solution
The React Animations library is a React wrapper for the Animate.css library. It will efficiently add animated styling to your components without generating unnecessary renders or significantly increasing the size of the generated DOM.
It’s able to work so efficiently because React Animations works with a CSS-in-JS library. CSS-in-JS is a technique for coding your
style information directly in your JavaScript code. React will let you add your style attributes as React components, but CSS-in-JS
does this more efficiently, dynamically creating shared style elements in the head
of the page.
There are several CSS-in-JS libraries to choose from, but in this recipe, we’re going to use one called Radium.
Let’s begin by installing Radium and React Animations:
$
npminstall
radium
$
npminstall
react-animations
Our example application (Figure 4-14) will run an animation each time we add an image item to the collection.
Likewise, when a user clicks an image, it shows a fade-out animation before removing the images from the list, as shown in Figure 4-15.4
We’ll begin by importing some animations and helper code from Radium:
import
{
pulse
,
zoomOut
,
shake
,
merge
}
from
'react-animations'
import
Radium
,
{
StyleRoot
}
from
'radium'
const
styles
=
{
created
:
{
animation
:
'x 0.5s'
,
animationName
:
Radium
.
keyframes
(
pulse
,
'pulse'
),
},
deleted
:
{
animation
:
'x 0.5s'
,
animationName
:
Radium
.
keyframes
(
merge
(
zoomOut
,
shake
),
'zoomOut'
),
},
}
From React Animations we get pulse
, zoomOut
, and shake
animations. We are going to use the pulse
animation when we add an
image. We’ll use a combined animation of zoomOut
and shake
when we remove an image. We can combine animations using React
Animations’ merge
function.
The styles
generate all of the CSS styles needed to run each of these half-second animations. The call to Radium.keyframes()
handles all of the animation details for us.
We must know when an animation has completely ended. If we delete an image before the deletion-animation completes, there would be no image to animate.
We can keep track of CSS animations by passing an onAnimationEnd
callback to any element we are going to animate. For each item in
our image collection, we are going to track three things:
-
The URL of the image it represents
-
A Boolean value that will be true while the “created” animation is running
-
A Boolean value that will be true while the “deleted” animation is running
Here is the example code to animate images into and out of the collection:
import
{
useState
}
from
'react'
import
{
pulse
,
zoomOut
,
shake
,
merge
}
from
'react-animations'
import
Radium
,
{
StyleRoot
}
from
'radium'
import
'./App.css'
const
styles
=
{
created
:
{
animation
:
'x 0.5s'
,
animationName
:
Radium
.
keyframes
(
pulse
,
'pulse'
),
},
deleted
:
{
animation
:
'x 0.5s'
,
animationName
:
Radium
.
keyframes
(
merge
(
zoomOut
,
shake
),
'zoomOut'
),
},
}
function
getStyleForItem
(
item
)
{
return
item
.
deleting
?
styles
.
deleted
:
item
.
creating
?
styles
.
created
:
null
}
function
App
()
{
const
[
data
,
setData
]
=
useState
([])
let
deleteItem
=
(
i
)
=>
setData
((
d
)
=>
{
const
result
=
[...
d
]
result
[
i
].
deleting
=
true
return
result
})
let
createItem
=
()
=>
{
setData
((
d
)
=>
[
...
d
,
{
url
:
`https://picsum.photos/id/
${
d
.
length
*
3
}
/200`
,
creating
:
true
,
},
])
}
let
completeAnimation
=
(
d
,
i
)
=>
{
if
(
d
.
deleting
)
{
setData
((
d
)
=>
{
const
result
=
[...
d
]
result
.
splice
(
i
,
1
)
return
result
})
}
else
if
(
d
.
creating
)
{
setData
((
d
)
=>
{
const
result
=
[...
d
]
result
[
i
].
creating
=
false
return
result
})
}
}
return
(
<
div
className
=
"App"
>
<
StyleRoot
>
<
p
>
Images
from
&
nbsp
;
<
a
href
=
"https://picsum.photos/"
>
Lorem
Picsum
</
a
>
</
p
>
<
button
onClick
=
{
createItem
}>
Add
</
button
>
{
data
.
map
((
d
,
i
)
=>
(
<
div
style
=
{
getStyleForItem
(
d
)}
onAnimationEnd
=
{()
=>
completeAnimation
(
d
,
i
)}
>
<
img
id
=
{
`image
${
i
}
`
}
src
=
{
d
.
url
}
width
=
{
200
}
height
=
{
200
}
alt
=
"Random"
title
=
"Click to delete"
onClick
=
{()
=>
deleteItem
(
i
)}
/>
</
div
>
))}
</
StyleRoot
>
</
div
>
)
}
export
default
App
Discussion
When choosing which animation to use, we should first ask: what will this animation mean?
All animation should have meaning. It can show something existential (creation or deletion). It might indicate a change of state (becoming enabled or disabled). It might zoom in to show detail or zoom out to reveal a broader context. Or it might illustrate a limit or boundary (a spring-back animation at the end of a long list) or allow a user to express a preference (swiping left or right).
Animation should also be short. Most animations should probably be over in half a second so that the user can experience the meaning of the animation without being consciously aware of its appearance.
An animation should never be merely attractive.
You can download the source for this recipe from the GitHub site.
4.8 Animate Infographics with TweenOne
Problem
CSS animations are smooth and highly efficient. Browsers might defer CSS animations to the graphics hardware at the compositing stage, which means that not only are the animations running at machine-code speeds, but the machine-code itself is not running on the CPU.
However, the downside to running CSS animations on graphics hardware is that your application code won’t know what’s happening
during an animation. You can track when an animation has started, ended, or is repeated (onAnimationStart
, onAnimationEnd
,
onAnimationIteration
), but everything that happens in between is a
mystery.
If you are animating an infographic, you may want to animate the numbers on a bar chart as the bars grow or shrink. Or, if you are writing an application to track cyclists, you might want to show the current altitude as the bicycle animates its way up and down the terrain.
But how do you create animations that you can listen to while they are happening?
Solution
The TweenOne library creates animations with JavaScript, which means you can track them as they happen, frame by frame.
Let’s begin by installing the TweenOne library:
$
npminstall
rc-tween-one
TweenOne works with CSS, but it doesn’t use CSS animations. Instead, it generates CSS transforms, which it updates many times each second.
You need to wrap the thing you want to animate in a <TweenOne/>
element. For example, let’s say we want to animate a rect
inside
an SVG:
<
TweenOne
component
=
'g'
animation
=
{...
details
here
}>
<
rect
width
=
"2"
height
=
"6"
x
=
"3"
y
=
"-3"
fill
=
"white"
/>
</
TweenOne
>
TweenOne
takes an element name and an object that will describe the animation to perform. We’ll come to what that animation object
looks like shortly.
TweenOne will use the element name (g
in this case) to generate a wrapper around the animated thing. This wrapper will have a
style attribute that will include a set of CSS transforms to move and rotate the contents somewhere.
So in our example, at some point in the animation, the DOM might look like this:
<
g
style
=
"transform: translate(881.555px, 489.614px) rotate(136.174deg);"
>
<
rect
width
=
"2"
height
=
"6"
x
=
"3"
y
=
"-3"
fill
=
"white"
/>
</
g
>
Although you can create similar effects to CSS animations, the TweenOne library works differently. Instead of handing the animation to the hardware, the TweenOne library uses JavaScript to create each frame, which has two consequences. First, it uses more CPU power (bad), and second, we can track the animation while it’s happening (good).
If we pass TweenOne
an onUpdate
callback, we will be sent information about the animation on every single frame:
<
TweenOne
component
=
'g'
animation
=
{...
details
here
}
onUpdate
=
{
info
=>{...}}>
<
rect
width
=
"2"
height
=
"6"
x
=
"3"
y
=
"-3"
fill
=
"white"
/>
</
TweenOne
>
The info
object passed to onUpdate
has a ratio
value between 0 and 1, representing the proportion of the way the TweenOne
element is through an animation. We can use the ratio
to animate text that is associated with the graphics.
For example, if we build an animated dashboard that shows vehicles on a race track, we can use onUpdate
to show each car’s speed
and distance as it animates.
We’ll create the visuals for this example in SVG. First, let’s create a string containing an SVG path, which represents the track:
export
default
'm 723.72379,404.71306 ... -8.30851,-3.00521 z'
This is a greatly truncated version of the actual path that we’ll use. We can import the path string from track.js like this:
import
path
from
'./track'
To display the track inside a React component, we can render an svg
element:
<
svg
height
=
"600"
width
=
"1000"
viewBox
=
"0 0 1000 600"
style
=
{{
backgroundColor
:
'black'
}}>
<
path
stroke
=
'#444'
strokeWidth
=
{
10
}
fill
=
'none'
d
=
{
path
}/>
</
svg
>
We can add a couple of rectangles for the vehicle—a red one for the body and a white one for the windshield:
<
svg
height
=
"600"
width
=
"1000"
viewBox
=
"0 0 1000 600"
style
=
{{
backgroundColor
:
'black'
}}>
<
path
stroke
=
'#444'
strokeWidth
=
{
10
}
fill
=
'none'
d
=
{
path
}/>
<
rect
width
=
{
24
}
height
=
{
16
}
x
=
{
-
12
}
y
=
{
-
8
}
fill
=
'red'
/>
<
rect
width
=
{
2
}
height
=
{
6
}
x
=
{
3
}
y
=
{
-
3
}
fill
=
'white'
/>
</
svg
>
Figure 4-16 shows the track with the vehicle at the top-left corner.
But how do we animate the vehicle around the track? TweenOne makes this easy because it contains a plugin to generate animations that follow SVG path strings.
import
PathPlugin
from
'rc-tween-one/lib/plugin/PathPlugin'
TweenOne
.
plugins
.
push
(
PathPlugin
)
We’ve configured TweenOne for use with SVG path animations. That means we can look at how to describe an animation for TweenOne. We do it with a simple JavaScript object:
import
path
from
'./track'
const
followAnimation
=
{
path
:
{
x
:
path
,
y
:
path
,
rotate
:
path
},
repeat
:
-
1
,
}
We tell TweenOne two things with this object: first, we’re telling it to generate translates and rotations that follow the path
string that we’ve imported from track.js. Second, we’re saying that we want the animation to loop infinitely by setting the
repeat
count to –1.
We can use this as the basis of animation for our vehicle:
<
svg
height
=
"600"
width
=
"1000"
viewBox
=
"0 0 1000 600"
style
=
{{
backgroundColor
:
'black'
}}>
<
path
stroke
=
'#444'
strokeWidth
=
{
10
}
fill
=
'none'
d
=
{
path
}/>
<
TweenOne
component
=
'g'
animation
=
{{...
followAnimation
,
duration
:
16000
}}>
<
rect
width
=
{
24
}
height
=
{
16
}
x
=
{
-
12
}
y
=
{
-
8
}
fill
=
'red'
/>
<
rect
width
=
{
2
}
height
=
{
6
}
x
=
{
3
}
y
=
{
-
3
}
fill
=
'white'
/>
</
TweenOne
>
</
svg
>
Notice that we’re using the spread operator to provide an additional animation parameter: duration
. A value of 16000 means we want
the animation to take 16
seconds.
We can add a second vehicle and use the onUpdate
callback method to create a very rudimentary set of faked telemetry statistics
for each one as they move around the track. Here is the completed code:
import
{
useState
}
from
'react'
import
TweenOne
from
'rc-tween-one'
import
Details
from
'./Details'
import
path
from
'./track'
import
PathPlugin
from
'rc-tween-one/lib/plugin/PathPlugin'
import
grid
from
'./grid.svg'
import
'./App.css'
TweenOne
.
plugins
.
push
(
PathPlugin
)
const
followAnimation
=
{
path
:
{
x
:
path
,
y
:
path
,
rotate
:
path
},
repeat
:
-
1
,
}
function
App
()
{
const
[
redTelemetry
,
setRedTelemetry
]
=
useState
({
dist
:
0
,
speed
:
0
,
lap
:
0
,
})
const
[
blueTelemetry
,
setBlueTelemetry
]
=
useState
({
dist
:
0
,
speed
:
0
,
lap
:
0
,
})
const
trackVehicle
=
(
info
,
telemetry
)
=>
({
dist
:
info
.
ratio
,
speed
:
info
.
ratio
-
telemetry
.
dist
,
lap
:
info
.
ratio
<
telemetry
.
dist
?
telemetry
.
lap
+
1
:
telemetry
.
lap
,
})
return
(
<
div
className
=
"App"
>
<
h1
>
Nürburgring
</
h1
>
<
Details
redTelemetry
=
{
redTelemetry
}
blueTelemetry
=
{
blueTelemetry
}
/>
<
svg
height
=
"600"
width
=
"1000"
viewBox
=
"0 0 1000 600"
style
=
{{
backgroundColor
:
'black'
}}
>
<
image
href
=
{
grid
}
width
=
{
1000
}
height
=
{
600
}
/>
<
path
stroke
=
"#444"
strokeWidth
=
{
10
}
fill
=
"none"
d
=
{
path
}
/>
<
path
stroke
=
"#c0c0c0"
strokeWidth
=
{
2
}
strokeDasharray
=
"3 4"
fill
=
"none"
d
=
{
path
}
/>
<
TweenOne
component
=
"g"
animation
=
{{
...
followAnimation
,
duration
:
16000
,
onUpdate
:
(
info
)
=>
setRedTelemetry
((
telemetry
)
=>
trackVehicle
(
info
,
telemetry
)
),
}}
>
<
rect
width
=
{
24
}
height
=
{
16
}
x
=
{
-
12
}
y
=
{
-
8
}
fill
=
"red"
/>
<
rect
width
=
{
2
}
height
=
{
6
}
x
=
{
3
}
y
=
{
-
3
}
fill
=
"white"
/>
</
TweenOne
>
<
TweenOne
component
=
"g"
animation
=
{{
...
followAnimation
,
delay
:
3000
,
duration
:
15500
,
onUpdate
:
(
info
)
=>
setBlueTelemetry
((
telemetry
)
=>
trackVehicle
(
info
,
telemetry
)
),
}}
>
<
rect
width
=
{
24
}
height
=
{
16
}
x
=
{
-
12
}
y
=
{
-
8
}
fill
=
"blue"
/>
<
rect
width
=
{
2
}
height
=
{
6
}
x
=
{
3
}
y
=
{
-
3
}
fill
=
"white"
/>
</
TweenOne
>
</
svg
>
</
div
>
)
}
export
default
App
Figure 4-17 shows the animation. The vehicles follow the path of the race track, rotating to face the direction of travel.
Discussion
CSS animations are what you should use for most UI animation. However, in the case of infographics, you often need to synchronize the text and the graphics. TweenOne makes that possible, at the cost of greater CPU usage.
You can download the source for this recipe from the GitHub site.
1 You can download all source code for this recipe on the GitHub repository.
2 See Recipe 4.5 for details on how to use Markdown in your application.
3 See the GitHub repository for the tests we used to drive out this code.
4 Paper books are beautiful things, but to fully experience the animation effect, see the complete code on GitHub.
Get React Cookbook now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.