Components allow us to break more complex problems into simpler reusable pieces that we can compose together.
React class components have lifecycle methods that we can tap into to perform certain actions
class Foo extends Component {
componentDidMount () {
this.#isMounted = true
this.setState({ isLoading: true })
fetchSomething(this.props.resourceId)
.then((data) =>
this.#isMounted &&
this.setState({ data, isLoading: false }))
.catch((error) =>
this.#isMounted &&
this.setState({ error, isLoading: false}))
}
componentDidUpdate (prevProps) {
if (prevProps.resourceId !== this.props.resourceId) {
fetchSomething(this.props.resourceId)
}
}
componentWillUnmount () {
this.#isMounted = false
}
render () {
return (
<pre>
<code>
{data || error}
</code>
</pre>
)
}
}
Lifecycle methods force us to spread logic, such as fetching and displaying data from an endpoint, across the lifecycle methods of the component. This works, but it prevents us from encapsulating that work and reusing it else where.
import React, { Component } from 'react'
class Foo extends Component {
componentDidMount () {
this.#isMounted = true
this.setState({ isLoading: true })
fetchSomething(this.props.resourceId)
.then((data) =>
this.#isMounted &&
this.setState({ data, isLoading: false }))
.catch((error) =>
this.#isMounted &&
this.setState({ error, isLoading: false}))
}
componentDidUpdate (prevProps) {
if (prevProps.resourceId !== this.props.resourceId) {
fetchSomething(this.props.resourceId)
}
}
componentWillUnmount () {
this.#isMounted = false
}
render () {
return (
<pre>
<code>
{this.state.data || this.state.error}
</code>
</pre>
)
}
}
Higher order components were one of the first patterns to emerge that allowed us to encapsulate related logic and make it shareable.
import React from 'react'
export const Foo = ({ data, error }) => (
<pre>
<code>
{data || error}
<code>
</pre>
export default withResource(resourceId)(Foo)
We could encapsulate sharable logic in an outer component, and render the inner component passing in the related data.
import React, { Component } from 'react'
export default (ComponentIn) => class Foo extends Component {
componentDidMount () {
this.#isMounted = true
this.setState({ isLoading: true })
fetchSomething(this.props.resourceId)
.then((data) =>
this.#isMounted &&
this.setState({ data, isLoading: false }))
.catch((error) =>
this.#isMounted &&
this.setState({ error, isLoading: false}))
}
componentDidUpdate (prevProps) {
if (prevProps.resourceId !== this.props.resourceId) {
fetchSomething(this.props.resourceId)
}
}
componentWillUnmount () {
this.#isMounted = false
}
render () {
return (
<ComponentIn
{...this.props}
data={this.state.data}
error={this.state.error}
/>)
}
}
The function as child, followed quickly by render props became another way to encapsulate logic and make it sharable.
import React from 'react'
import Resource from './resource'
export const Foo = ({ resourceId }) => (
<Resource resourceId={resourceId}>
{({ data, error }) => (
<pre>
<code>
{data || error}
<code>
</pre>
)
</Resource>
export default withResource(resourceId)(Foo)
import React, { Component } from 'react'
export default class Resource extends Component {
componentDidMount () {
this.#isMounted = true
this.setState({ isLoading: true })
fetchSomething(this.props.resourceId)
.then((data) =>
this.#isMounted &&
this.setState({ data, isLoading: false }))
.catch((error) =>
this.#isMounted &&
this.setState({ error, isLoading: false}))
}
componentDidUpdate (prevProps) {
if (prevProps.resourceId !== this.props.resourceId) {
fetchSomething(this.props.resourceId)
}
}
componentWillUnmount () {
this.#isMounted = false
}
render () {
return this.props.children({
data: this.state.data,
error: this.state.error
})
}
}
In this pattern, a `render` prop or the `children` prop is expected to be a function that is called with the relevant data. The consumer then returns the desired UI.
Although promoted by the React team, each of this patterns were developed by the React community to fulfill a need that the React library did not provide natively.
It is a testament to React's flexibility and versatility that these patterns were able to emerge and gain footholds quickly.
After several years of React in the wild, and with a number of patterns emerging to solve the same kinds of problems -- each with their own benefits and drawbacks -- Hooks is the React team's official answer to several of React's long time inefficiencies, including the problem of encapsulated logic.
Like the other patterns, hooks enables us to encapsulate and reuse logic.
import React, { useState, useEffect } from 'react'
const Foo ({ resourceId }) => {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
fetchSomething(resourceId)
.then(setData)
.catch(setError)
}, [resourceId])
return (
<pre>
<code>
{data || error}
</code>
</pre>
)
}
Unlike the other patterns, hooks don't use lifecycle methods, at least not directly. In fact, they can't be used in class components at all.
import React, { Component } from 'react'
class Foo extends Component {
componentDidMount () {
this.#isMounted = true
this.setState({ isLoading: true })
fetchSomething(this.props.resourceId)
.then((data) =>
this.#isMounted &&
this.setState({ data, isLoading: false }))
.catch((error) =>
this.#isMounted &&
this.setState({ error, isLoading: false}))
}
componentDidUpdate (prevProps) {
if (prevProps.resourceId !== this.props.resourceId) {
fetchSomething(this.props.resourceId)
}
}
componentWillUnmount () {
this.#isMounted = false
}
render () {
return (
<pre>
<code>
{this.state.data || this.state.error}
</code>
</pre>
)
}
}
React exposes a number of hooks for use out of the box. These hooks are extremely useful on their own, but become even more powerful when combined to create custom hooks.
import React, { useState, useEffect } from 'react'
const Foo ({ resourceId }) => {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
fetchSomething(resourceId)
.then(setData)
.catch(setError)
}, [resourceId])
return (
<pre>
<code>
{data || error}
</code>
</pre>
)
}
// use-resource.js
import { useState, useEffect } from 'react'
export default (resourceId) => {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
fetchSomething(resourceId)
.then(setData)
.catch(setError)
}, [resourceId])
return { data, error }
}
import React from 'react'
import useResource from './use-resource'
const Foo ({ resourceId }) => {
const { data, error } = useResource(resourceId)
return (
<pre>
<code>
{data || error}
</code>
</pre>
)
}
Check out the official docs for all the available 'primitive' hooks and how they work.
For the most part, you can think of hooks as plain javascript functions that may close over some values. There are a couple additional constraints to be aware of though.
With the release of Hooks, React has taken a stance on how components should be tested.
"We recommend using react-testing-library which is designed to encourage writing tests that use your components as the end users do."
import React from 'react'
import { render, cleanup } from 'react-testing-library'
import * as useResourceModule from './use-resource.js'
describe('<Foo />', () => {
afterEach(cleanup)
it('renders data if data is present', () => {
const useResourceMock = jest.spyOn(useResourceModule, 'default')
.mockImplementation(() => ({ data: 'data' }))
const { container, getByText} = render(<Foo />)
expect(getByText(container, 'data')).not.toThrow()
useResourceMock.mockRestore()
})
it('renders error if error is present', () => {
const useResourceMock = jest.spyOn(useResourceModule, 'default')
.mockImplementation(() => ({ error: 'error' }))
const { container, getByText} = render(<Foo />)
expect(getByText(container, 'error')).not.toThrow()
useResourceMock.mockRestore()
})
})
react-testing-library provides utilities for testing custom hooks.
Tests for our useResource custom hook might look something like this.
import {testHook, wait, cleanup} from 'react-testing-library'
import * as fetchResourceModule from './fetch-resource'
import useResource from './use-resource'
describe('useResource', () => {
let fetchResourceSpy
beforeEach(() => {
fetchResourceSpy = jest.spyOn(fetchResourceModule, 'default')
.mockImplementation(() => Promise.resolve(42))
})
afterEach(() => {
fetchResourceSpy.mockRestore()
cleanup()
})
it('accepts a resource id and calls `fetchResource with it`', () => {
testHook(() => useResource(1))
expect(fetchResourceSpy).toBeCalledWith(1)
})
it('returns an object with data and error properties', () => {
let resource
testHook(() => (resource = useResource(resourceId)))
expect(resource).toEqual(expect.objectContaining({ data: null, error: null }))
})
it('populates data when resolved', () => {
let resource
testHook(() => { resource = await wait(useResource(resourceId)) })
expect(resource).toEqual(expect.objectContaining({ data: 42, error: null }))
})
it('populates error when rejected', () => {
const fetchResourceSpy = jest.spyOn(fetchResourceModule, 'default')
.mockImplementation(() => Promise.reject('fail'))
let resource
testHook(() => {
resource = await wait(() => resource = useResource(resourceId))
})
expect(resource).to(expect.objectContaining({ data: null, error: 'fail' }))
fetchResourceSpy.mockRestore()
})
})
React uses a declarative pattern for creating UI. A declarative model expresses intent -- or the "what" -- and abstracts the implementation -- or the "how". It is one of React's strongest features.
It has been an unfortunate truth that within the declarative composition of components, the component definitions themselves have been a jumble of imperative and declarative code. In the `render` component method expressing the UI's intent, and in the others the implementation of that component's logic spread across several lifecycle methods.
The intent of hooks, is to provide a way for developers to express intent within a component, and implementation within the hooks.