Custom Hooks to the Rescue!
My name is Iago.
A feature provided by libraries for isolating, reusing and composing stateful logic.
A concept independently of library...
...that allows us to handle stateful logic, detached from the view.
A new hook, being composed by thirdy party hooks or other custom hooks.
import React, { useState } from 'react' const Counter = () => { const [count, setCount] = useState(0) return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}> Click me </button> </div> ) }
import { useState } from 'react' const useCounter = (initialState) => { const [count, setCount] = useState(initialState) return { count, increment: () => setCount(count + 1), } }
import React from 'react' import useCounter from '...' const Counter = () => { const { count, increment } = useCounter(0) return ( <div> <p>You clicked {count} times</p> <button onClick={() => increment()}> Click me </button> </div> ) }
Coupling...
...is the degree of interdependence between software modules.
Coupling in UI libraries...
A. Custom hooks should obey every other rules dictated by the library you are using.
B. Custom hooks should not leak or reveal the technology being used underneath.
C. Custom hooks should not expose the shape of the state being stored.
D. A custom hook should not cover too much state.
useArticles
import React from 'react' import useArticles from '...' const Articles = () => { const {} = useArticles() return ( <div> {/* List articles */} </div> ) }
State Shape
{ list: [ { id: 1, title: 'Frontend architecture' }, { id: 2, title: 'Common patterns' } ], currentPage: 2, currentArticle: { id: 2, title: 'Common patterns', content: "Let's continue...", comments: { status: 'LOADED', list: [ { userName: "Taly 'The Architect' Son", content: "Isn't 'Common patterns' a redudant title?" } ] } } }
{ list: [ { id: 1, title: 'Frontend architecture' }, { id: 2, title: 'Common patterns' } ], currentPage: 2, currentArticle: { id: 2, title: 'Common patterns', content: "Let's continue...", comments: { status: 'LOADED', list: [ { userName: "Taly 'The Architect' Son", content: "Isn't 'Common patterns' a redudant title?" } ] } } }
{ list: [ { id: 1, title: 'Frontend architecture' }, { id: 2, title: 'Common patterns' } ], currentPage: 2, currentArticle: { id: 2, title: 'Common patterns', content: "Let's continue...", comments: { status: 'LOADED', list: [ { userName: "Taly 'The Architect' Son", content: "Isn't 'Common patterns' a redudant title?" } ] } } }
{ list: [ { id: 1, title: 'Frontend architecture' }, { id: 2, title: 'Common patterns' } ], currentPage: 2, currentArticle: { id: 2, title: 'Common patterns', content: "Let's continue...", comments: { status: 'LOADED', list: [ { userName: "Taly 'The Architect' Son", content: "Isn't 'Common patterns' a redudant title?" } ] } } }
useArticles
import React from 'react' import useArticles from '...' const Articles = () => { const { articles, currentPage } = useArticles() return ( <div> {/* List articles */} </div> ) }
useArticles
import React from 'react' import useArticles from '...' const Articles = () => { const { articles, currentPage, isFirstPage, isLastPage, isLoading, hasError } = useArticles() return ( <div> {/* List articles */} </div> ) }
Actions β
import React from 'react' import useArticles from '...' const Articles = () => { const { articles, currentPage, isFirstPage, isLastPage, isLoading, hasError, setCurrentPage, loadPage, setIsLoading } = useArticles() return ( <div> {/* List articles */} </div> ) }
Actions β
import React from 'react' import useArticles from '...' const Articles = () => { const { articles, currentPage, isFirstPage, isLastPage, isLoading, hasError, goToNextPage, goToPreviousPage } = useArticles() return ( <div> {/* List articles */} </div> ) }
Redux
useSelector and useDispatch
useSelector
{ "super": { "nested": { "object": {} } } } const slice = useSelector(state => state.super.nested.object)
useDispatch
const dispatch = useDispatch() dispatch({ type: 'REDUX_ACTION' })
Redux
import { useSelector, useDispatch } from 'react-redux' const useArticles = () => { return { articles: undefined currentPage: undefined isFirstPage: undefined isLastPage: undefined isLoading: undefined hasError, : undefined goToNextPage, : undefined goToPreviousPage : undefined } }
Redux
import { useSelector, useDispatch } from 'react-redux' import { /* selectors */ } from './redux' const useArticles = () => { const articles = useSelector(getArticlesList) const currentPage = useSelector(getCurrentPage) const pageMetadata = useSelector(getPageMetadata) const pageStatus = useSelector(getPageStatus) const error = useSelector(getError) return { articles, currentPage, isFirstPage: ArticleFeedPage.isFirstPage(currentPage), isLastPage: ArticleFeedPage.isLastPage(currentPage), isLoading: status === 'loading', hasError: Boolean(error), goToNextPage: undefined, goToPreviousPage: undefined, } }
import { useSelector, useDispatch } from 'react-redux' import { /* selectors */ } from './redux' const useArticles = () => { const articles = useSelector(getArticlesList) const currentPage = useSelector(getCurrentPage) const pageMetadata = useSelector(getPageMetadata) const pageStatus = useSelector(getPageStatus) const error = useSelector(getError) return { articles, currentPage, isFirstPage: ArticleFeedPage.isFirstPage(currentPage), isLastPage: ArticleFeedPage.isLastPage(currentPage), isLoading: status === 'loading', hasError: Boolean(error), goToNextPage: undefined, goToPreviousPage: undefined, } }
import { useSelector, useDispatch } from 'react-redux' import { /* selectors */ } from './redux' const useArticles = () => { const articles = useSelector(getArticlesList) const currentPage = useSelector(getCurrentPage) const pageMetadata = useSelector(getPageMetadata) const pageStatus = useSelector(getPageStatus) const error = useSelector(getError) return { articles, currentPage, isFirstPage: ArticleFeedPage.isFirstPage(currentPage), isLastPage: ArticleFeedPage.isLastPage(currentPage), isLoading: status === 'loading', hasError: Boolean(error), goToNextPage: undefined, goToPreviousPage: undefined, } }
import { useSelector, useDispatch } from 'react-redux' import { /* selectors */ } from './redux' const useArticles = () => { const articles = useSelector(getArticlesList) const currentPage = useSelector(getCurrentPage) const pageMetadata = useSelector(getPageMetadata) const pageStatus = useSelector(getPageStatus) const error = useSelector(getError) return { articles, currentPage, isFirstPage: ArticleFeedPage.isFirstPage(currentPage), isLastPage: ArticleFeedPage.isLastPage(currentPage), isLoading: status === 'loading', hasError: Boolean(error), goToNextPage: undefined, goToPreviousPage: undefined, } }
Redux
import { useSelector, useDispatch } from 'react-redux' import { /* actions, selectors */ } from './redux' const useArticles = () => { const articles = useSelector(getArticlesList) const currentPage = useSelector(getCurrentPage) const pageMetadata = useSelector(getPageMetadata) const pageStatus = useSelector(getPageStatus) const error = useSelector(getError) const dispatch = useDispatch() const goToNextPage = () => dispatch(goToNextPageAction()) const goToPreviousPage = () => dispatch(goToPreviousPageAction()) return { articles, currentPage, isFirstPage: ArticleFeedPage.isFirstPage(currentPage), isLastPage: ArticleFeedPage.isLastPage(currentPage), isLoading: status === 'loading', hasError: Boolean(error), goToNextPage, goToPreviousPage, } }
import { useSelector, useDispatch } from 'react-redux' import { /* actions, selectors */ } from './redux' const useArticles = () => { const articles = useSelector(getArticlesList) const currentPage = useSelector(getCurrentPage) const pageMetadata = useSelector(getPageMetadata) const pageStatus = useSelector(getPageStatus) const error = useSelector(getError) const dispatch = useDispatch() const goToNextPage = () => dispatch(goToNextPageAction()) const goToPreviousPage = () => dispatch(goToPreviousPageAction()) return { articles, currentPage, isFirstPage: ArticleFeedPage.isFirstPage(currentPage), isLastPage: ArticleFeedPage.isLastPage(currentPage), isLoading: status === 'loading', hasError: Boolean(error), goToNextPage, goToPreviousPage, } }
import { useSelector, useDispatch } from 'react-redux' import { /* actions, selectors */ } from './redux' const useArticles = () => { const articles = useSelector(getArticlesList) const currentPage = useSelector(getCurrentPage) const pageMetadata = useSelector(getPageMetadata) const pageStatus = useSelector(getPageStatus) const error = useSelector(getError) const dispatch = useDispatch() const goToNextPage = () => dispatch(goToNextPageAction()) const goToPreviousPage = () => dispatch(goToPreviousPageAction()) return { articles, currentPage, isFirstPage: ArticleFeedPage.isFirstPage(currentPage), isLastPage: ArticleFeedPage.isLastPage(currentPage), isLoading: status === 'loading', hasError: Boolean(error), goToNextPage, goToPreviousPage, } }
import { useSelector, useDispatch } from 'react-redux' import { /* actions, selectors */ } from './redux' const useArticles = () => { const articles = useSelector(getArticlesList) const currentPage = useSelector(getCurrentPage) const pageMetadata = useSelector(getPageMetadata) const pageStatus = useSelector(getPageStatus) const error = useSelector(getError) const dispatch = useDispatch() const goToNextPage = () => dispatch(goToNextPageAction()) const goToPreviousPage = () => dispatch(goToPreviousPageAction()) return { articles, currentPage, isFirstPage: ArticleFeedPage.isFirstPage(currentPage), isLastPage: ArticleFeedPage.isLastPage(currentPage), isLoading: status === 'loading', hasError: Boolean(error), goToNextPage, goToPreviousPage, } }
Context API: Provider
import { createContext, useState } from 'react' const ArticlesContext = createContext() const ArticlesProvider = ({ children }) => { const [articles, setArticles] = useState([]) const [currentPage, setCurrentPage] = useState(-1) const [pageMetadata, setPageMetadata] = useState(null) const [pageStatus, setPageStatus] = useState(PageStatus.IDLE) const [error, setError] = useState(null) const goToNextPage = async () => { // ... } const goToPreviousPage = async () => { // ... } const contextValue = { articles, currentPage, isFirstPage: ArticleFeedPage.isFirst(pageMetadata), isLastPage: ArticleFeedPage.isLast(pageMetadata), isLoading: pageStatus === PageStatus.LOADING, isLoaded: pageStatus === PageStatus.LOADED, error, goToNextPage, goToPreviousPage } return ( <ArticlesContext.Provider value={contextValue}> {children} </ArticlesContext.Provider> ) }
Context API
import { useContext } from 'react' import { ArticlesContext } from './context' const useArticles = () => useContext(ArticlesContext)
import { useContext } from 'react' import { ArticlesContext } from './context' const useArticles = () => useContext(ArticlesContext)
import { useContext } from 'react' import { ArticlesContext } from './context' const useArticles = () => useContext(ArticlesContext)
View
State
Mocking Paths
import { render } from '@testing-library/react' import { Article } from '../components/article/Article' import { useCurrentArticle } from '../state/article/hooks' jest.mock('../../state/article/hooks') describe('<Article />', () => { describe('when article does not have comments', () => { it('renders a message about not having comments', () => { useCurrentArticle.mockReturnValue({ id: 42, title: 'Something', content: 'This is the content', comments: [] }) const { getByText } = render(<Article />) expect( getByText('No comments yet, be the first') ).toBeInTheDocument() }) }) })
import { render } from '@testing-library/react' import { Article } from '../components/article/Article' import { useCurrentArticle } from '../state/article/hooks' jest.mock('../../state/article/hooks') describe('<Article />', () => { describe('when article does not have comments', () => { it('renders a message about not having comments', () => { useCurrentArticle.mockReturnValue({ id: 42, title: 'Something', content: 'This is the content', comments: [] }) const { getByText } = render(<Article />) expect( getByText('No comments yet, be the first') ).toBeInTheDocument() }) }) })
import { render } from '@testing-library/react' import { Article } from '../components/article/Article' import { useCurrentArticle } from '../state/article/hooks' jest.mock('../../state/article/hooks') describe('<Article />', () => { describe('when article does not have comments', () => { it('renders a message about not having comments', () => { useCurrentArticle.mockReturnValue({ id: 42, title: 'Something', content: 'This is the content', comments: [] }) const { getByText } = render(<Article />) expect( getByText('No comments yet, be the first') ).toBeInTheDocument() }) }) })
Mocking Provider
import { render } from '@testing-library/react' import { Article } from '../../components/article/Article' import { CurrentArticleProvider } from '../../state/article/providers' describe('<Article />', () => { describe('when article does not have comments', () => { it('renders a message about not having comments', () => { const FakeProvider = ({ children }) => { return ( <CurrentArticleProvider value={{ id: 42, title: 'Something', content: 'This is the content', comments: [] }} > {children} </CurrentArticleProvider> ) } const { getByText } = render(<Article />, { wrapper: CurrentArticleProvider }) expect( getByText('No comments yet, be the first') ).toBeInTheDocument() }) }) })
import { render } from '@testing-library/react' import { Article } from '../../components/article/Article' import { CurrentArticleProvider } from '../../state/article/providers' describe('<Article />', () => { describe('when article does not have comments', () => { it('renders a message about not having comments', () => { const FakeProvider = ({ children }) => { return ( <CurrentArticleProvider value={{ id: 42, title: 'Something', content: 'This is the content', comments: [] }} > {children} </CurrentArticleProvider> ) } const { getByText } = render(<Article />, { wrapper: CurrentArticleProvider }) expect( getByText('No comments yet, be the first') ).toBeInTheDocument() }) }) })
import { render } from '@testing-library/react' import { Article } from '../../components/article/Article' import { CurrentArticleProvider } from '../../state/article/providers' describe('<Article />', () => { describe('when article does not have comments', () => { it('renders a message about not having comments', () => { const FakeProvider = ({ children }) => { return ( <CurrentArticleProvider value={{ id: 42, title: 'Something', content: 'This is the content', comments: [] }} > {children} </CurrentArticleProvider> ) } const { getByText } = render(<Article />, { wrapper: CurrentArticleProvider }) expect( getByText('No comments yet, be the first') ).toBeInTheDocument() }) }) })
@iagodahlem