I’ve completely rewritten two projects with React Hooks, here is the good and the ugly

unbug
17 min readFeb 14, 2019

--

My Slider for Chengdu FEDAY 2019.

Table of Contents:

  • Introducing React Hooks resources
  • The benefit you can get from React Hooks
  • APIs you’ll be frequently used
  • Get started

1. Update ReactJS
2. Install the ESLint Plugin
3. Turn a Class Component into a React Function with React Hooks
4. Let’s do one more refactoring
5. How about integrating with third-party libraries?
6. The easiest way to refactor a Container/Control Component
7. Fix code minify issue

  • Summary

Facebook just released the official version — 16.8 of ReactJS. We finally can use state in React Functions and get away from “Wrapper Hell”. That’s the power of the new feature — React Hooks.

I’ve completely rewritten two of my projects with React Hooks. One is CODELF (A search tool helps dev to solve the naming variable/function problem, GitHub stars 8.5k+). If you’re a game dev or you're struggling with naming things, I believe CODELF is worth to check out. Another one is way larger and complicated which belongs to my company, so any sensitive code of it won’t show in this article.

If you haven’t hearted any of React Hooks, I highly recommend you to check it out right now:

  1. Introducing React Hooks.
  2. YouTube video — React Today and Tomorrow and 90% Cleaner React With Hooks.
  3. React Hooks “Hello World”.
  4. All new APIs of React Hooks.
  5. Everything you need to know about React Hooks.

The benefit you can get from React Hooks:

  1. Manipulate states and interact with component lifecycle methods in your React Functions.
  2. Reuse components state/lifecycle logic become possible, and state/lifecycle logic can be tested easily. Reuse components never become so easier.
  3. Get rid of Higher-Order Components, Context Providers to avoid “Wrapper Hell”.
  4. Avoid frightened by bloated Class Components and no more “xx.bind(this)”, reduce component logic and easy to maintain.
  5. Avoid potential performance issues and bugs by making wrong use of component lifecycle method, no more suffering from component lifecycle methods.
  6. Integrate with third-party libraries become easier and make a lot of sense.
  7. Saving your time from thinking about “state VS props”.
  8. Have a better experience with Function Programming and Middleware Programming.

APIs you’ll be frequently used:

  1. useState: manipulate states in your React Functions.
  2. useEffect: combined the component lifecycle methods of componentDidMount, componentDidUpdate, and componentWillUnmount
  3. useContext: return React.createContext as a value, no more “Wrapper Hell”.
  4. useReducer: that’s right, ReactJS now ships ReduxJS.
  5. useMemo: cache expensive calculations to make the render faster.
  6. useRef: enhance of React.createRef().
  7. useDebugValue: enhance method for React DevTools.

Well, let’s get started

1. Update ReactJS

We’ll need to update react to 16.8. Don’t worry, the update won’t break any of our codes, because 16.8 is totally backward-compatible and React Hooks is totally opt-in.

npm update

2. Install the ESLint Plugin

React Hooks can only be used at the top level of React functions. Don’t call Hooks inside loops, conditions, or nested functions. Install eslint-plugin-react-hooks to enforces the rules.

npm install eslint-plugin-react-hooks

Then update our “.eslint.js” file:

{
"plugins": [
// ...
"react-hooks"
],
"rules": {
// ...
"react-hooks/rules-of-hooks": "error"
}
}

3. Turn a Class Component into a React Function with React Hooks

The origin codes without React Hooks.

TL, DR:

import React from 'react';
import {Dropdown, Icon, Input} from 'semantic-ui-react';
// http://githut.info/
const topProgramLan = [
//...
];
export default class SearchBar extends React.Component {
input = React.createRef();
select = React.createRef();
constructor(props) {
super(props);
this.state = {
lang: props && props.searchLang ? props.searchLang : [],
prevProps: props,
inputSize: 'huge',
inputChanged: false
}
window.addEventListener('resize', this.resizeInput, false)
}
static getDerivedStateFromProps(nextProps, prevState) {
// avoid calculating expensive derived data
if (prevState.prevProps === nextProps) {
return null
}
let newState = {
prevProps: nextProps // prevProps memoization
}
// derived state from props
if (prevState.prevProps.searchLang != nextProps.searchLang) {
newState.lang = nextProps.searchLang;
}
return newState;
}
componentDidMount() {
this.resizeInput();
}
resizeInput = () => {
this.setState({inputSize: document.body.offsetWidth < 800 ? '' : 'huge'})
}
handleSearch = () => {
this.setState({inputChanged: false});
this.props.onSearch(this.input.current.inputRef.value, this.state.lang);
this.input.current.inputRef.blur();
}
handleRestLang = () => {
this.setState({lang: [], inputChanged: true});
}
handleSelectLang = id => {
this.setState({lang: this.state.lang.concat(id).sort(), inputChanged: true});
}
handleDeselectLang = id => {
let lang = this.state.lang;
lang.splice(this.state.lang.indexOf(id), 1);
this.setState({lang: lang.sort(), inputChanged: true});
}
handleToggleSelectLang = id => {
this.state.lang.indexOf(id) === -1 ? this.handleSelectLang(id) : this.handleDeselectLang(id);
}
renderItems() {
return topProgramLan.map(key => {
const active = this.state.lang.indexOf(key.id) !== -1;
return <Dropdown.Item key={key.id}
active={active}
onClick={() => this.handleToggleSelectLang(key.id)}>
<Icon name={active ? 'check circle outline' : 'circle outline'}/>{key.language}
</Dropdown.Item>;
})
}
render() {
return (
<div className='search-bar'>
<div className='search-bar__desc'>
Search over GitHub, Bitbucket, GitLab to find real-world usage variable names
</div>
<form action="javascript:void(0);">
<Input ref={this.input}
onChange={() => this.setState({inputChanged: true})}
className='search-bar__input'
icon fluid placeholder={this.props.placeholder} size={this.state.inputSize}>
<Dropdown floating text='' icon='filter' className='search-bar__dropdown'>
<Dropdown.Menu>
<Dropdown.Item icon='undo' text='All 90 Languages (Reset)' onClick={this.handleRestLang}/>
<Dropdown.Menu scrolling className='fix-dropdown-menu'>
{this.renderItems()}
</Dropdown.Menu>
</Dropdown.Menu>
</Dropdown>
<input type='search' name='search' defaultValue={this.props.searchValue}
onKeyPress={e => {
e.key === 'Enter' && this.handleSearch()
}}/>
<Icon name={(this.props.variableList.length && !this.state.inputChanged) ? 'search plus' : 'search'}
link onClick={() => this.handleSearch()}/>
</Input>
</form>
<div className='search-bar__plugins'>
Extensions:&nbsp;
<a href='https://github.com/unbug/codelf#codelf-for-vs-code'
target='_blank' rel='noopener noreferrer'>VS Code</a>,&nbsp;
<a className='text-muted' href='https://atom.io/packages/codelf'
target='_blank' rel='noopener noreferrer'>Atom</a>,&nbsp;
<a className='text-muted' href='https://github.com/unbug/codelf#codelf-for-sublime-text'
target='_blank' rel='noopener noreferrer'>Sublime</a>,&nbsp;
<a href='https://github.com/unbug/codelf/issues/24'
target='_blank' rel='noopener noreferrer'>WebStorm</a>,&nbsp;
<a href='https://github.com/unbug/codelf/issues/63'
target='_blank' rel='noopener noreferrer'>Alfred</a>
</div>
</div>
)
}
}

The code includes multiple states, lifecycle methods( even a getDerivedStateFromProps method), event handlers, and react refs.

Here is what we gonna do:

  • Turn the class into a function export default function SearchBar(props)
  • Replace all this.props. and this.state.to an empty string.
  • Remove the wrap of render() {//body} function and keep the body codes.
  • Turn all the class methods into pure functions.
  • Refactor class state with React Hooks API useState() .
  • Refactor the all the input resize logic and window.addEventListener(‘resize’, this.resizeInput, false) with React Hooks API useEffect()
  • Refactor values created by React.createRef() with React Hooks API useRef(null) .

And here is how the new codes

import React, {useEffect, useRef, useState} from 'react';
import {Dropdown, Icon, Input} from 'semantic-ui-react';
// http://githut.info/
const topProgramLan = [
//...
];
export default function SearchBar(props) {
const inputEl = useRef(null);
const [state, setState] = useState({
lang: props && props.searchLang ? props.searchLang : [],
valChanged: false
});
const [inputSize, setInputSize] = useState('huge');
useEffect(() => {
resizeInput();
window.addEventListener('resize', resizeInput, false);
return () => window.removeEventListener('resize', resizeInput, false);
}, []);// run an effect and clean it up only once (on mount and unmount), you can pass an empty array ([])
function resizeInput() {
setInputSize(document.body.offsetWidth < 800 ? '' : val);
}
function updateState(vals) {
setState(prevState => {
return {...prevState, ...vals};
});
}
function handleSearch() {
props.onSearch(inputEl.current.inputRef.value, state.lang);
inputEl.current.inputRef.blur();
updateState({valChanged: false});
}
function handleRestLang() {
updateState({lang: [], valChanged: true});
}
function handleSelectLang(id) {
updateState({lang: state.lang.concat(id).sort(), valChanged: true});
}
function handleDeselectLang(id) {
let lang = state.lang;
lang.splice(state.lang.indexOf(id), 1);
updateState({lang: lang.sort(), valChanged: true});
}
function handleToggleSelectLang(id) {
state.lang.indexOf(id) === -1 ? handleSelectLang(id) : handleDeselectLang(id);
}
const langItems = topProgramLan.map(key => {
const active = state.lang.indexOf(key.id) !== -1;
return <Dropdown.Item key={key.id}
active={active}
onClick={() => handleToggleSelectLang(key.id)}>
<Icon name={active ? 'check circle outline' : 'circle outline'}/>{key.language}
</Dropdown.Item>;
});
return (
<div className='search-bar'>
<div className='search-bar__desc'>
Search over GitHub, Bitbucket, GitLab to find real-world usage variable names
</div>
<form action="javascript:void(0);">
<Input ref={inputEl}
onChange={() => updateState({valChanged: true})}
className='search-bar__input'
icon fluid placeholder={props.placeholder} size={inputSize}>
<Dropdown floating text='' icon='filter' className='search-bar__dropdown'>
<Dropdown.Menu>
<Dropdown.Item icon='undo' text='All 90 Languages (Reset)' onClick={handleRestLang}/>
<Dropdown.Menu scrolling className='fix-dropdown-menu'>
{langItems}
</Dropdown.Menu>
</Dropdown.Menu>
</Dropdown>
<input type='search' name='search' defaultValue={props.searchValue}
onKeyPress={e => {
e.key === 'Enter' && handleSearch()
}}/>
<Icon name={(props.variableList.length && !state.valChanged) ? 'search plus' : 'search'}
link onClick={() => handleSearch()}/>
</Input>
</form>
<div className='search-bar__plugins'>
Extensions:&nbsp;
<a href='https://github.com/unbug/codelf#codelf-for-vs-code'
target='_blank' rel='noopener noreferrer'>VS Code</a>,&nbsp;
<a className='text-muted' href='https://atom.io/packages/codelf'
target='_blank' rel='noopener noreferrer'>Atom</a>,&nbsp;
<a className='text-muted' href='https://github.com/unbug/codelf#codelf-for-sublime-text'
target='_blank' rel='noopener noreferrer'>Sublime</a>,&nbsp;
<a href='https://github.com/unbug/codelf/issues/24'
target='_blank' rel='noopener noreferrer'>WebStorm</a>,&nbsp;
<a href='https://github.com/unbug/codelf/issues/63'
target='_blank' rel='noopener noreferrer'>Alfred</a>
</div>
</div>
)
}

Save the code, nothing break, and everything works fine. This component is CODELF’s search bar, it looks like below

CODELF’s search bar

Two minutes’ work, how is that? As you can see, without all the lifecycle methods, the code becomes simpler, especially no getDerivedStateFromProps which is ugly and make no sense all the time.

The useRef() is very easy to understand. Let’s take look at the part of useState() :

//...
const
[state, setState] = useState({
lang: props && props.searchLang ? props.searchLang : [],
valChanged: false
});
function updateState(vals) {
setState(prevState => {
return {...prevState, ...vals};
});
}
//...

Unlike setState() in React Class, set method returns from useState() won’t merge new state into the old state for us, that’s why we do an ugly wrapper here. If there’re many states the wrapper will help, but in this case, we can just define two states instead:

//...
const
[lang, setLang] = useState(props && props.searchLang ? props.searchLang : []);
const [valChanged, setValChanged] = useState(false)//...

Each time we call a set method of a state, an update of the component is trigged, but when we call multiple set methods at the same time, react automatically avoid trigger duplicates updates, only trigger once.

If useState() returns a state with a setter, then we won’t need a wrapper for multiple states.

Keep up, we’re almost there. Let’s take a look at the part of useEffect() :

//...const [inputSize, setInputSize] = useState('huge');useEffect(() => {
resizeInput();
window.addEventListener('resize', resizeInput, false);
return () => window.removeEventListener('resize', resizeInput, false);
}, []);// run an effect and clean it up only once (on mount and unmount), you can pass an empty array ([])
function resizeInput() {
setInputSize(document.body.offsetWidth < 800 ? '' : val);
}
//...

What we’re doing here is change the input size base on the viewport size. This is something we can reuse somewhere, how about we extract the state logic as a pure function out of the component?

//...export default function SearchBar(props) {//...
const inputSize = useInputSize('huge');
//...
}
function useInputSize(val) {
const [size, setSize] = useState(val);
useEffect(() => {
resizeInput();
window.addEventListener('resize', resizeInput, false);
return () => window.removeEventListener('resize', resizeInput, false);
}, []);// run an effect and clean it up only once (on mount and unmount), you can pass an empty array ([])
function resizeInput() {
setSize(document.body.offsetWidth < 800 ? '' : val);
}
return size;
}

All the logic is in one place! Impressive clean, hum? You’re right, we just define a custom hook! There are some key points in this custom hook:

  1. The custom hook should start with use so that eslint-plugin-react-hooks won’t missing it.
  2. We remove the event listener from the return function within useEffect() , sort of remove event listener within componentWillUnmount . React effects actually run on every update — when a state has changed. The return function helps us clean up the effect, such as remove event listener. React also cleans up effects from the previous render before running the effects next time.
  3. The second parameter of useEffect() is an array, we can specify a list of state/props to watch, and only the watched state/props will trigger the effect. In this case, we watch nothing, so the effect will run on the mount and clean up on the unmount. But the props and state inside the effect will always have their initial values.

4. Let’s do one more refactoring

This component is rendering the variable list for CODELF as below.

variable list for CODELF

The origin codes without React Hooks.

export default class VariableList extends React.Component {  lastPageLen = 0;
animationName = Math.random() > 0.5 ? 'zoomInDown' : 'zoomInUp';
renderPage() {
let pages = [];
const pageLen = this.props.variableList.length;
this.props.variableList.forEach((list, i) => {
const isLast = i === pageLen - 1 && this.lastPageLen != pageLen;
const variables = list.map((variable, j) => {
let style = {}, className = '', duration = (list.length - j) / list.length;
if (isLast) {
className = 'animated';
style = {
animationName: this.animationName,
animationDelay: duration + 's',
animationDuration: Math.min(duration, 0.8) + Math.random() + 's'
};
}
return <Variable key={Tools.uuid()} variable={variable}
onOpenSourceCode={this.props.onOpenSourceCode} style={style} className={className}/>
});
if (variables && variables.length) {
if (pages.length) {
pages.unshift(<hr/>);
}
Array.prototype.unshift.apply(pages, variables)
}
});
this.lastPageLen = pageLen;
return pages;
}
render() {
return (
<div className='variable-list'>
{this.renderPage()}
</div>
)
}
}

What is special about this case? This Class component includes instance variables (animationName and lastPageLen), expensive calculation (renderPage()).

Let’s turn it into React Function with React Hooks

import React, {useMemo, useState} from 'react';
import * as Tools from '../utils/Tools';
import VariableItem from './VariableItem';
const animationName = Math.random() > 0.5 ? 'zoomInDown' : 'zoomInUp';export default function VariableList(props) {
const [lastPageLen, setLastPageLen] = useState(0);
const list = useMemo(() => { // same as "shouldComponentUpdate", only compute when "variableList" has changed
const variableList = props.variableList;
const pageLen = variableList.length;
let pages = [];
variableList.forEach((list, i) => {
const isLast = i === pageLen - 1 && lastPageLen != pageLen;
const variables = list.map((variable, j) => {
let style = {}, className = '', duration = (list.length - j) / list.length;
if (isLast) {
className = 'animated';
style = {
animationName: animationName,
animationDelay: duration + 's',
animationDuration: Math.min(duration, 0.8) + Math.random() + 's'
};
}
return <VariableItem key={Tools.uuid()} variable={variable}
onOpenSourceCode={props.onOpenSourceCode} style={style} className={className}/>
});
if (variables && variables.length) {
if (pages.length) {
pages.unshift(<hr/>);
}
Array.prototype.unshift.apply(pages, variables)
}
});
setLastPageLen(pageLen);
return pages;
}, [props.variableList]);
return <div className='variable-list'>{list}</div>;
}
  • animationName is mean to cache only the first initial value. So we move it out of the React Function.
  • lastPageLen has to update when props.variableList have updated, and the update can only be set after renderPage() done its calculation. So the way we do here turns it into a state.
  • useMemo() help us to avoid expensive calculation by only watch change of props.variableList . It also avoids circular call setLastPageLen(pageLen) .

The solution for refactoring lastPageLen is pretty tricky, it’s not recommended. The right approach for this case is to turn it into a useRef() . We can cache any value in ref.current , similar to an instance property on a class.

//...
export default function VariableList(props) {
const lastPageLen = useRef();
const list = useMemo(() => {
//...
variableList.forEach((list, i) => {
const isLast = i === pageLen - 1 && lastPageLen.current != pageLen;
//.....
});
lastPageLen.current = pageLen;
return pages;
}, [props.variableList]);

return <div className='variable-list'>{list}</div>;
}

We can also turn animationName into a useRef() so each component has its own initial animation name. useRef() also looks like a hack, but unfortunately it is the only option to deal with instance property.

5. How about integrating with third-party libraries?

CODELF has a component allows us to view the source code where the matched real-world variable name belongs to. CODELF also ship a daily Algorithm Copybook component to help us practice algorithm daily without pressure. Both of them require to integrate with google/code-prettify.

The origin codes without React Hooks for both of them pretty much looks the same. Here is the source code viewer component.

import React from 'react';
import {Modal, Button, Dropdown, Label} from 'semantic-ui-react';
import * as Tools from '../utils/Tools';
import Loading from "./Loading";

export default class SourceCode extends React.Component {
code = React.createRef();
mark = null;
visible = false;
constructor(props) {
super(props);
}

componentDidUpdate(prevProps, prevState, snapshot) {
if (prevProps.sourceCode != this.props.sourceCode || (!this.visiable && this.props.sourceCodeVisible)) {
this.renderPrettyPrint();
this.visible = true;
}
}

componentDidMount() {
this.renderPrettyPrint();
}

handleClose = () => {
this.visiable = false;
this.props.onCloseSourceCode();
}
renderPrettyPrint = () => {
setTimeout(() => {
if (this.code.current) {
this.code.current.classList.remove('prettyprinted');
setTimeout(() => PR.prettyPrint(
() => setTimeout(() =>this.renderHighLight(), 1000)
), 100);
}
}, this.code.current ? 0 : 1000);
}

renderHighLight = () => {
if (this.mark) {this.mark.unmark()}
this.mark = new Mark(this.code.current);
let idx = 0;
this.mark.mark(this.props.sourceCodeVariable.keyword, {each: el => {
el.setAttribute('tabindex',idx++);
}});
}

renderDropdownItem() {
if (!this.props.sourceCodeVariable) { return null; }
return this.props.sourceCodeVariable.repoList.map(repo => {
return (
<Dropdown.Item key={Tools.uuid()}>
<Button size='mini' onClick={() => this.props.onRequestSourceCode(repo)}>Codes</Button>
<Button size='mini' as='a' href={repo.repo} target='_blank'>Repo</Button>
<Label size='mini' circular color={Tools.randomLabelColor()}>{repo.language}</Label>
</Dropdown.Item>
)
});
}

render() {
if (!this.props.sourceCodeVariable || !this.props.sourceCodeRepo) { return null; }
const sourceCodeVariable = this.props.sourceCodeVariable;
const dropText = (
<div>All Codes <Label size='mini' circular color={sourceCodeVariable.color}>
{sourceCodeVariable.repoList.length}
</Label></div>
);
return (
<Modal open={this.props.sourceCodeVisible} onClose={this.handleClose}
centered={false} closeIcon className='source-code fix-modal' size='large'>
<Modal.Header>
<Dropdown floating labeled button blurring className='mini icon' style={{padding: '0.35rem 0'}}
text={dropText}>
<Dropdown.Menu>
<Dropdown.Menu scrolling className='fix-dropdown-menu'>
{this.renderDropdownItem()}
</Dropdown.Menu>
</Dropdown.Menu>
</Dropdown>
<Button size='mini' as='a' href={this.props.sourceCodeRepo.repo} target='_blank'>Repo</Button>
</Modal.Header>
<Modal.Content>
{this.props.requestingSourceCode ? <Loading/> : ''}
<pre><code className='prettyprint linenums' ref={this.code}>{this.props.sourceCode}</code></pre>
</Modal.Content>
</Modal>
)
}
}

We’ll extract the part of integrating with google/code-prettify into a custom hook src/components/hooks/useCodeHighlighting.js

import React, {useEffect, useRef} from 'react';

export default function useCodeHighlighting(watchedProps, keyword) {
const container = useRef(null);
const mark = useRef(null);
useEffect(() => {
renderPrettyPrint();
}, [...watchedProps]);

function renderPrettyPrint() {
setTimeout(() => {
if (container.current) {
container.current.classList.remove('prettyprinted');
setTimeout(() => PR.prettyPrint(
() => setTimeout(() => renderHighLight(), 1000)
), 100);
}
}, container.current ? 0 : 1000);
}

function renderHighLight() {
if (!keyword) {
return;
}
if (mark.current) {
mark.current.unmark()
}
mark.current = new Mark(container.current);
let idx = 0;
mark.current.mark(keyword, {
each: el => {
el.setAttribute('tabindex', idx++);
}
});
}

return container;
}

We also integrate with markjs in this hook for mark matched keyword in the source codes.

Now let’s refactor the old codes with the hook helper for source code viewer component.

import React from 'react';
import {Modal, Button, Dropdown, Label} from 'semantic-ui-react';
import * as Tools from '../utils/Tools';
import Loading from "./Loading";
import useCodeHighlighting from './hooks/useCodeHighlighting';

export default function SourceCode(props) {
const codeEl = React.createRef();
useCodeHighlighting(codeEl, [props.sourceCode, props.sourceCodeVisible], props.sourceCodeVariable?.keyword);

function handleClose() {
props.onCloseSourceCode();
}

if (!props.sourceCodeVariable || !props.sourceCodeRepo) { return null; }
const sourceCodeVariable = props.sourceCodeVariable;
const dropText = (
<div>All Codes <Label size='mini' circular color={sourceCodeVariable.color}>
{sourceCodeVariable.repoList.length}
</Label></div>
);
const dropdownItems = props.sourceCodeVariable && props.sourceCodeVariable.repoList.map(repo => {
return (
<Dropdown.Item key={Tools.uuid()}>
<Button size='mini' onClick={() => props.onRequestSourceCode(repo)}>Codes</Button>
<Button size='mini' as='a' href={repo.repo} target='_blank'>Repo</Button>
<Label size='mini' circular color={Tools.randomLabelColor()}>{repo.language}</Label>
</Dropdown.Item>
)
});
return (
<Modal open={props.sourceCodeVisible} onClose={handleClose}
centered={false} closeIcon className='source-code fix-modal' size='large'>
<Modal.Header>
<Dropdown floating labeled button blurring className='mini icon' style={{padding: '0.35rem 0'}}
text={dropText}>
<Dropdown.Menu>
<Dropdown.Menu scrolling className='fix-dropdown-menu'>
{dropdownItems}
</Dropdown.Menu>
</Dropdown.Menu>
</Dropdown>
<Button size='mini' as='a' href={props.sourceCodeRepo.repo} target='_blank'>Repo</Button>
</Modal.Header>
<Modal.Content>
{props.sourceCodeRequesting ? <Loading/> : ''}
<pre><code className='prettyprint linenums' ref={codeEl}>{props.sourceCode}</code></pre>
</Modal.Content>
</Modal>
)
}

And let’s reuse the hook in daily Algorithm Copybook component.

import React, {useEffect, useRef} from 'react';
import {Button, Dropdown, Modal} from 'semantic-ui-react';
import Loading from "./Loading";
import useCodeHighlighting from './hooks/useCodeHighlighting';

export default function Copybook(props) {
const codeEl = useCodeHighlighting([props.copybookFileContent, props.copybookVisible]);
const editorEl = useRef(null);

function handleClose() {
props.onCloseCopybook();
}

function handleDropdownChange(e, { searchQuery, value }) {
if (value != props.copybookSelectedFile.path) {
props.onRequestCopybookFile(
props.copybookFileList.find(f => f.path === value)
);
}
}

function renderDropdownItem() {
if (!props.copybookFileList) {
return null;
}
return props.copybookFileList.map((file, idx) => {
return {
key: file.path,
value: file.path,
text: (idx + 1) + '. ' + file.path
}
});
}

if (!props.copybookVisible || !props.copybookFileList || !props.copybookFileContent) {
return (
<Modal open={props.copybookVisible} onClose={handleClose}
centered={false} closeIcon className={props.className} size='large'>
<Modal.Header>
<div className='title'>Daily Algorithm Copybook</div>
</Modal.Header>
<Modal.Content>
<Loading/>
<pre><code className='prettyprint' ref={codeEl}></code></pre>
</Modal.Content>
</Modal>
);
}

return (
<Modal open={props.copybookVisible} onClose={handleClose}
centered={false} closeIcon className={props.className} size='large'>
<Modal.Header>
<div className='title'>Daily Algorithm Copybook</div>
<Button size='tiny' as='a' basic
href={props.copybookSelectedFile.link}
target='_blank'>View In GitHub</Button>
<Dropdown
search
selection
onChange={handleDropdownChange}
value={props.copybookSelectedFile.path}
options={renderDropdownItem()}/>
</Modal.Header>
<Modal.Content>
{props.copybookRequesting ? <Loading/> : ''}
<pre>
<code className='prettyprint' ref={codeEl}>{props.copybookFileContent.content}</code>
<div className='editor' contentEditable={true} ref={editorEl}></div>
</pre>
</Modal.Content>
</Modal>
)
}

Here we go, both of the new components nothing break works like a charm. Integrate with third-party libraries never been so easy!

Daily Algorithm Copybook for CODELF
Source Code Viewer for CODELF

6. The easiest way to refactor a Container/Control Component

You might take advantage of Container pattern as I do. A complicate Container/Control Component could handle too many states/props/data. That’s the challenge of refactoring it. Let’s see an example.

import React from 'react';
import AppModel from '../models/AppModel';
import CopybookModel from '../models/CopybookModel';
import Copybook from '../components/Copybook';

export default class CopybookContainer extends React.Component {
state = {
copybookRequesting: false,
copybookVisible: CopybookModel.visible,
copybookFileList: CopybookModel.fileList,
copybookSelectedFile: CopybookModel.selectedFile,
copybookFileContent: CopybookModel.fileContent,
}

constructor(props) {
super(props);
CopybookModel.onUpdated(this.handleCopybookModelUpdate);
}

handleCopybookModelUpdate = (curr, prev, mutation) => {
if (mutation.fileList) {
this.setState({
copybookFileList: CopybookModel.fileList
});
}
if (mutation.fileContent) {
this.setState({
copybookRequesting: false,
copybookSelectedFile: CopybookModel.selectedFile,
copybookFileContent: CopybookModel.fileContent,
});
}
if (mutation.visible) {
this.setState({
copybookVisible: CopybookModel.visible,
});
if (CopybookModel.visible) {
AppModel.analytics('copybook&q=read');
}
}
}

handleCloseCopybook = () => {
CopybookModel.update({ visible: false });
}

handleRequestCopybookFile = file => {
this.setState({ copybookRequesting: true });
CopybookModel.requestRepoFile(file);
AppModel.analytics('copybook&q=read');
}

render() {
return <Copybook {...this.state}
className='copybook-container fix-modal'
onRequestCopybookFile={this.handleRequestCopybookFile}
onCloseCopybook={this.handleCloseCopybook}/>;
}
}

As you can see, we update multiple states in multiple methods. Its too much efforts to turn each state property into a useState() . Here comes the useReducer() hook save our life. It works as same as ReduxJS.

import React, {useEffect, useReducer} from 'react';
import AppModel from '../models/AppModel';
import CopybookModel from '../models/CopybookModel';
import Copybook from '../components/Copybook';

const actionTypes = {
UPDATE: 'update',
};

const initState = {
copybookRequesting: false,
copybookVisible: CopybookModel.visible,
copybookFileList: CopybookModel.fileList,
copybookSelectedFile: CopybookModel.selectedFile,
copybookFileContent: CopybookModel.fileContent,
};

function reducer(state, action) {
switch (action.type) {
case actionTypes.UPDATE:
return {
...state,
...action.payload
};
default:
return state;
}
}

export default function CopybookContainer(props) {
const [state, dispatch] = useReducer(reducer, initState);

useEffect(() => {
CopybookModel.onUpdated(handleCopybookModelUpdate);
return () => {
CopybookModel.offUpdated(handleCopybookModelUpdate);
}
});

function setState(payload) {
dispatch({type: actionTypes.UPDATE, payload: payload});
}

function handleCopybookModelUpdate(curr, prev, mutation) {
if (mutation.fileList) {
setState({
copybookFileList: CopybookModel.fileList
});
}
if (mutation.fileContent) {
setState({
copybookRequesting: false,
copybookSelectedFile: CopybookModel.selectedFile,
copybookFileContent: CopybookModel.fileContent,
});
}
if (mutation.visible) {
setState({
copybookVisible: CopybookModel.visible,
});
if (CopybookModel.visible) {
AppModel.analytics('copybook&q=read');
}
}
}

function handleCloseCopybook() {
CopybookModel.update({visible: false});
}

function handleRequestCopybookFile(file) {
setState({copybookRequesting: true});
CopybookModel.requestRepoFile(file);
AppModel.analytics('copybook&q=read');
}

return <Copybook {...state}
className='copybook-container fix-modal'
onRequestCopybookFile={handleRequestCopybookFile}
onCloseCopybook={handleCloseCopybook}/>;
}

We initialize all the state with a useReducer() , then define an update action, then dispatch update action within a setSate() wrapper, so we can update state as the old way without change much of the codes. 2 minutes’ work, hooray!

Off course, useState({copybookRequesting: false, ...}) can do the same thing. But useReducer() is easier to define actions to handle complicated logic and keep the component clean, which is making it more scalable. useState() is great for “logicless” component. That’s a big difference use case between useState() and useReducer() .

7. Fix code minify issue

If you minify your codes with mishoo/UglifyJS, you properly have the same issue as me: useEffect fails with `Cannot set property ‘lastEffect’ of null` . It is a known issue of UglifyJS for React Hooks, you can replace your minify tool with terser-js/terser or babel/minify.

Minify with UglifyJS Error

Summary

CODELF is a small project, so the refactoring is a happy journey. The project gets clean and easy to maintain. You can find the refactored codes here https://github.com/unbug/codelf/tree/master/src, and the old codes here https://github.com/unbug/codelf/tree/legacy-without-react-hooks/src .

I got all the benefits listed at the beginning of this article. But I can only refactor all of the small components for another way large project. Because many Container/Control Components are way complicated.

Well, React Hooks is new, I believe its worth to try, but refactor a project is really not recommended unless you have all the effort. Create a new component with React Hooks is a good start.

Here are the ugly things of React Hooks keep bothering me during the refactoring:

  • useState() doesn't return a setter and it won’t merge new state into the old state automatically, that’s sucks then setState() of React Class Components.
  • React effects run on every update. Which make cache local values as instance properties in React Class Components is difficult. We have to be very careful otherwise it will lead to bugs. Unfortunately, the ESLint plugin doesn’t cover this kind of case. Such as, we define a local variable let val = null then update the val somewhere in the component, but if an update has been trigged, the val will be reset as null again.
  • A completely rewritten for a large project will cost a huge effort. But if we don’t rewrite all the components, reuse new components state logic for old components is impossible, we also can’t reuse state logic in a React Class components, duplicate components, and codes will be made.
  • You can turn a React Function into a React Class, or turn a React Class into a React Function. But turn a React Function with React Hooks into React Class is hard, sometimes even impossible. It will be a pain to copy codes from a project to another project.
  • There is no way to handle Error Boundaries with React Hooks right now, which means you won’t be able to refactor a React Class that handle errors with componentDidCatchand getDerivedStateFromError . But it is a very common use case, right?

Above are what I’ve learned, don’t hesitate to leave any comments, please!

--

--