Hook View Controller
React has changed frontend web development for the better. It is simple to write useful components, but it is complex to take simple components and turn them into a big page with complex business logic. Hooks have made this better, but it’s not enough. We’ve seen that components end up being structured in a wild number of ways, that has lead to complex and unreadable code.
-
Hook are good - They allow for re-usability of business logic. Some great examples include React Table and React Hook Form, and there are a lot of other great examples.
-
Stateless components are good - React allows components to be written as pure functions, which makes them super easy to re-use and manage. Styled Components and design systems are leading the charge in simplifying building quality UI from off-the-shelf components.
-
Combining Hooks and getting it into the UI is broken - where it seems like most developers fall down is in how to plug these things together.
Hook, View, Controller is a best practice that tries to fix this.
- The View is a stateless component, void of business logic or side-effects. It is responsible for looking good.
- The Hook has no components. It’s responsible for all your business logic and API calls.
- The Controller is 1 line of code, a component such as
const Counter = () => <View {...useHook()} />
If you do this, it makes working with Storybook and Jest easier and more logical. At SaaSquatch we take that one step further with View Developers and Hook Developers as specialized roles that fit different types of software developers (see The Great Divide). View developers focus on UX. Hook developers focus on state machines.
A toy example
To demonstrate this, let’s use React’s default example from their docs, a counter (see below).
import React, { useState } from 'react';
export function Example() {
// Declare a new state variable, which we'll call "count"
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
To transform this into the Hook, View, Controller approach, we just need to write a few function names.
import React, { useState } from 'react';
export function Example() {
return <View {...useCounter()} />
}
export function useCounter(){
const [count, setCount] = useState(0);
const increment = ()=>setCount(c=>c+1);
return { count, increment }
}
export function View({ count, increment }){
return (
<div>
<p>You clicked {count} times</p>
<button onClick={increment}>
Click me
</button>
</div>
);
}
What’s changed here is;
useCounter
does all the state and business logicView
is statelessExample
still exists, it’s just implemented differently
This might seem like a wasted layer of indrection, but let’s try mixing in some new requirements, storybook and testing.
Adding requirements
Lets change this up by adding some requirements from our data model. Our simple counter is now getting a bit more complicated.
Scenario: Counter stops at current user's age
Given a user is 35
Then the counter will not allow values over 35
And an error will be displayed
To power this we need a value from our database or data model, for convenience we’ll call it useDbValue
but it could be a useQuery
or useFetch
or some other type of backend data lookup.
If we follow the simple example, and implement it, this is what we get.
import React, { useState } from 'react';
import useDbValue from './useDbValue';
export function Example() {
// Declare a new state variable, which we'll call "count"
const [count, setCount] = useState(0);
const age = useDbValue("age");
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => count < age ? setCount(count + 1) : null} disabled={age<count}>
Click me
</button>
</div>
);
}
Notice how important business logic is now embedded inside of the increment button. This kind of coding decision happens all the time.
Instead let’s break out the business logic from the view, and keep that in the hook.
import React, { useState } from 'react';
import useDbValue from "./useDbValue";
export function Example() {
return <View {...useCounter()} />
}
export function useCounter(){
const [count, setCount] = useState(0);
const age = useDbValue("age");
const canIncrement = count < age;
const increment = ()=> canIncrement && setCount(c=>c+1);
return { count, increment, canIncrement }
}
export function View({ count, increment, canIncrement }){
return (
<div>
<p>You clicked {count} times</p>
<button onClick={increment} disabled={canIncrement}>
Click me
</button>
</div>
);
}
The above code should be much easier to understand when broken out in this way.
Adding styling
Now let’s improve the user experience here by swapping out some of the styling of this component.
Here’s the original version, evolved to add more details
import React, { useState } from 'react';
import useDbValue from './useDbValue';
export function Example() {
// Declare a new state variable, which we'll call "count"
const [count, setCount] = useState(0);
const age = useDbValue("age");
return (
<div>
<p style={{color:(age == count ? "red":"black")}}>You clicked {count} times</p>
<button onClick={() => count < age ? setCount(count + 1) : null} disabled={age<count}>
Click me
</button>
</div>
);
}
Versus lets look at our hook-view-controller example:
import React, { useState } from 'react';
import useDbValue from './useDbValue';
export function Example() {
return <View {...useCounter()} />
}
export function useCounter(){
const [count, setCount] = useState(0);
const age = useDbValue("age");
const canIncrement = count < age;
const increment = ()=> canIncrement && setCount(c=>c+1);
return { count, increment, canIncrement }
}
export function View({ count, increment, canIncrement }){
return (
<div>
<p style={{color: canIncrement ? "black":"red"}}>You clicked {count} times</p>
<button onClick={increment} disabled={!canIncrement}>
Click me
</button>
</div>
);
}
Writing stories
If we’re not using Hook-View-Controller, then we need to mock up all our database context so that useDbValue
returns values, and we can’t easily mock up the internal state, so we’re stuck there without refactoring our component.
export const Default = ()=> <MockDatabase><Example /></MockDatabase>
When the View is written separately then writing stories becomes a lot easier.
const props = {count: 10, increment: ()=>console.log("Increment"), canIncrement: true};
export const Default = ()=> <View {...props} />
export const Zero = ()=> <View {...props} count={0} />
export const NoIncrement = ()=> <View {...props} canIncrement={false} />
export const AgelessBeing = ()=> <View {...props} count={13371337} canIncrement={true} />
You provide your states into the stories, making it a lot faster to discover cases where a component isn’t properly showing a state. It also makes visual regression testing using tools like Percy a lot easier.
Writing Tests
I’m going to skip what it takes to write unit tests for the non-modified version from above.
import Example from './Counter'
// Code that nobody ever has time to maintain since breaks with every visual update
But with HVC, the hooks can now be directly tested using hooks testing library. If the user experience changes, we’re not left trying to manage selectors for which button to press, we can just call the callbacks directly.
import { renderHook, act } from '@testing-library/react-hooks'
import useCounter from './Counter'
test('should increment counter', () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
Since your business logic is separated out, your new requirements for canIncrement
can be easily tested programmatically.
import { renderHook, act } from '@testing-library/react-hooks'
import useCounter from './Counter'
// Mock the database
import useDbValue from './useDbValue';
jest.mock('./useDbValue');
test('cant increment past age', () => {
useDbValue.mockReturnValue(0);
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.canIncrement).toBe(false)
expect(result.current.count).toBe(0)
})
Conclusion
- Hooks are great.
- Views (Stateless Components) are great.
- The React ecosystem is moving towards separating the two.
- We need the right vocabulary for explaining this difference and the standards for how to use them together in our apps.
It doesn’t matter if you’re using Redux and Material design; Styled Components and Apollo; or Emotion and Unstated. This way of structuring your components will lead to easier-to-maintain test suites, more complete and meaningful storybooks, and logical specialization of people in different roles based on their skillsets.