In my last post I discussed how writing your own hooks can encapsulate imperative code in useful and reusable objects, leaving your components simple and completely declarative.
In this post I explain the same concept with a simpler example and less code. And perhaps, more importantly, this will give us room to test-drive it and experience the benefits of TDD. Here we go...
Imagine we want to be able to try out various fonts right in the app we are building. It's hard to get a sense of what a font will look like until it's viewed in place, so easily cycling through a few fonts in context would be handy, like this:
Writing a Test
Let's pretend this wasn't a (somewhat) contrived example but an actual feature in our app. We start off by writing a test using the React Testing Library.
// src/Title.spec.js
import Title from './Title'
test('Cycles through a list of fonts when clicked', () => {
const text = 'Clickable Fonts'
const { getByText } = render(<Title>{text}</Title>)
const fontBefore = window.getComputedStyle(getByText(text)).fontFamily
fireEvent.click(getByText(text))
const fontAfter = window.getComputedStyle(getByText(text)).fontFamily
expect(fontBefore).not.toEqual(fontAfter)
})
There are some problems with this test, not the least of which is that testing CSS is not a great idea, but we don't yet know how our component is going to work, except from the user perspective. And changing the style when it is clicked is the feature, so this will get us going.
As expected, our test is failing. (Red, green, refactor, right?)
Making the Test Pass
To make the test pass, we create a Title
component, add some Google Fonts, a bit of style via Styled-Components, a useState
hook to keep track of which font is currently being displayed and an onClick
handler to change the font. We end up with this:
// src/Title.js
function Title({ children }) {
const [fontIndex, setFontIndex] = React.useState(0)
const handleChangeFont = () =>
setFontIndex(fontIndex >= fontList.length - 1 ? 0 : fontIndex + 1)
const fontList = [
'Indie Flower',
'Sacramento',
'Mansalva',
'Emilys Candy',
'Merienda One',
'Pompiere',
]
const fontFamily = fontList[fontIndex]
const StyledTitle = styled.h1`
font-size: 3rem;
cursor: pointer;
user-select: none;
font-family: ${fontFamily};
`
return <StyledTitle onClick={handleChangeFont}>{children}</StyledTitle>
}
That makes our test pass, yay.
And the component works as seen in this CodeSandbox demo.
We Can Make this Better
We have some problems with this. We'd like our component to be more declarative. It's currently showing all the nitty-gritty details about how the font gets changed when a user clicks on it.
There is also the problem that something just doesn't feel right about testing the CSS in the component. But let's solve the first problem first since that is easy enough.
We'll just push all the logic into our own custom hook.
Our new hook looks like this:
// src/useClickableFonts.js
const useClickableFonts = fontList => {
const [fontIndex, setFontIndex] = React.useState(0)
const handleChangeFont = () =>
setFontIndex(fontIndex >= fontList.length - 1 ? 0 : fontIndex + 1)
const fontFamily = fontList[fontIndex]
return { fontFamily, handleChangeFont }
}
Our component looks like this:
// src/Title.js
function Title({ children }) {
const { fontFamily, handleChangeFont } = useClickableFonts([
'Indie Flower',
'Sacramento',
'Mansalva',
'Emilys Candy',
'Merienda One',
'Pompiere',
])
const StyledTitle = styled.h1`
font-size: 3rem;
cursor: pointer;
user-select: none;
font-family: ${fontFamily};
`
return <StyledTitle onClick={handleChangeFont}>{children}</StyledTitle>
}
Notice we left the declaration of the fonts in the component, passing them into the hook. This is important because it is part of what we want components to do, declare all of their possible states. We just don't want them to know how they get into those states.
The Styled-Components API is also completely declarative and is part of the implementation of the component. It stays.
Our tests still pass so we know we haven't broken anything. Refactoring is fun with the security of tests.
And our component still works: (CodeSandbox demo).
Adding the Font Name to the Footer
As we are clicking endlessly on it, we realize it would be nice to know which font is currently being displayed. However, we want that info far away from the Title
component, so that it doesn't interfere with the UX design testing we are doing. Let's display it subtle-like in the footer for now.
But how do we get that font information out of the Title
component and on to the page in a different location?
The answer, of course, is to lift state up. Luckily, pushing logic and state into our own hook has made this task as simple as moving the useClickableFonts
line up and passing down the props.
// src/App.js
function App() {
const { fontFamily, handleChangeFont } = useClickableFonts([
'Indie Flower',
'Sacramento',
'Mansalva',
'Emilys Candy',
'Merienda One',
'Pompiere',
])
return (
<>
<Title fontFamily={fontFamily} handleChangeFont={handleChangeFont}>
Clickable Fonts
</Title>
<Footer>{fontFamily}</Footer>
</>
)
}
Great, we moved the hook up to the closest common ancestor (in this simple example it is App
) and we passed the props into the Title
component and displayed the name of the font in the Footer
.
The Title
component becomes a pure, deterministic component:
// src/Title.js
function Title({ fontFamily, handleChangeFont, children }) {
const StyledTitle = styled.h1`
font-size: 3rem;
cursor: pointer;
user-select: none;
font-family: ${fontFamily};
`
return <StyledTitle onClick={handleChangeFont}>{children}</StyledTitle>
}
Now we can see the name of the font down at the footer. Go ahead, click it:
However, our test is now broken. (See the CodeSandbox demo with the broken test.)
Fixing the test
This gives us some insight into why we had that gnawing feeling something was wrong with our test. When we update the component to take props instead of using the useClickableFont
hook directly, that requires us to update the test as well. However, it was slightly unexpected because we didn't change or refactor any of the logic.
Our test was brittle because we were testing the wrong thing. We need to test that the imperative gears of changing the font work, not the (now) simple and declarative React component. The nuts and bolts of React and Styled-Components are already well tested. We can just use them with confidence if we are not adding our own logic.
This doesn't mean we should be testing implementation details. When writing our own hooks, we are adding to the API that our React component will use. We need to test that new API, but from the outside.
What we really want to be testing is our useClickableFont
hook. We can do that with the react-hooks-testing-library
Our new test looks like this:
// src/useClickableFonts.spec.js
import useClickableFonts from './useClickableFonts'
test('Cycles through a list of fonts', () => {
const { result } = renderHook(() =>
useClickableFonts(['Indie Flower', 'Sacramento', 'Mansalva']),
)
expect(result.current.fontFamily).toBe('Indie Flower')
act(() => result.current.handleChangeFont())
expect(result.current.fontFamily).toBe('Sacramento')
act(() => result.current.handleChangeFont())
expect(result.current.fontFamily).toBe('Mansalva')
act(() => result.current.handleChangeFont())
expect(result.current.fontFamily).toBe('Indie Flower')
})
Notice we are testing it from the outside, just like the user would use it. The test should resemble the way the hook is used. In this case the user is a React component. We can have confidence in this new test because the test uses it just like a component would.
We test that the hook returns the first, second and third font in order, each time the handler is called. We also test that it loops around to the first one again.
Here is the final component on CodeSandbox:
Conclusion
It's not always easy to know the right design or the correct abstraction at first. That's why the refactor part of the red, green, refactor
cycle is so important and ignoring this step is often the cause of code deterioration and growing technical debt.
Often, separating the tasks of making the code work and making the code right creates freedom. Freedom to get started, and then freedom to discover a better implementation.
We test-drove a new component, discovering an initial implementation. Extracting the logic into a hook made our code easier to change. Changing it helped us discover a better way to test it.
We ended up with clean, declarative components and the hook gives us a convenient interface to test and reuse imperative code.
Top comments (5)
Do you find any benefit exporting a destructured object instead of a destructured array with your custom hooks?
Interesting question. I was just thinking of doing a post on this, but the short of it is that each have their use cases.
A hook that has very general use cases benefits from exporting return values as an array. A great example of this is actually the built-in
useState
hook. By exporting an array with two values, it makes it easy to customize the names of the state variables and their setters and to use them repeatedly:Hooks that are more custom or specific to a component or set of components and return a larger number of values may benefit by returning an object. Object destructuring doesn't rely on ordering and it is easier to ignore values that are not needed. An example might be a hook that 3 or 4 state values along with handler functions:
There are tradeoffs and some overlap, so it comes down to figuring out what the best API is for each specific hook.
I'll try to come up with some better examples in a future post.
Thanks, great answer! I don't think anyone would mind if you still made a blogpost about it. But you answered my question
Thanks for this 👍 I haven't used custom hooks yet, this is just what I need to get started.
You're welcome! It starts to make a lot of sense pretty quickly.