0%

Learned React for long times. And have created many repositories. But, it is not an easy way to findout if long time to used them. So I made all related react repository into my best-practice-in-react monorepos.
Here is my steps for creating lerna monorepos.

1. Prerequisite

1
2
3
4
5
yarn global add lerna
mkdir best-practice-in-react
cd best-practice-in-react
lerna init
touch README.md

The repository looks like below.

1
2
3
4
5
best-practice-in-react/
packages/
package.json
lerna.json
README.md

For better performance, we will go with YARN. So how do we configure YARN with Lerna?
It’s pretty simple! We just need to add "npmClient": "yarn" to lerna.json

2. Using YARN workspaces with Lerna

add "useWorkspaces": true to lerna.json and in package.json, we need to add

1
2
3
"workspaces": [
"packages/*"
]

Once this is done, we can run command below, it is means forcefully telling Lerna to link the packages. As of now, we do not have anything under it, but we can run it to check if this setup can bootstrap and work properly.

1
2
3
yarn install
or
lerna bootstrap

Because we have added workspaces in package.json, both commands above are the same.

### 3. Installing create-react-app and create-react-library
We will use create-react-app & create-react-library to create a base for our libraries and modules.

1
2
3
yarn create react-library ui-components
yarn create react-library common-utils
yarn create react-app my-app

I don’t know why create-library use react 16.13.1, so I changed

3. Create packages under in packages folder

1
2
yarn create react-app todo-app-js
lerna run start --scope todo-app-js

We can see the React website.

Next we add prefix @best-practice-in-reace in todo-app-js package.json.

package.json
1
"name": "@best-practice-in-react/todo-app-js

Then run commands in terminate

1
2
lerna clean
lerna run start --scop @best-practice-in-react/todo-app-js

For convience, make a script in the package.json

1
2
3
4
5
"scripts": {
"clean": "lerna clean",
"bootstrap": "lerna bootstrap",
"start:todo-app-js": "lerna run start --scope @best-practice-in-react/todo-app-js"
}

So, the command we can run in terminal as

1
2
3
yarn clean
yarn bootstrap
yarn start:todo-app-js

Next we create another typescript version todo app under in packages folder.

1
yarn create react-app todo-app-ts --template=typescript

So far, both applications are created by create-react-app, next we can setup a react from zero.

4. Elegant commit

. commitizen && cz-lerna-changelog

1
2
yarn add commitizen -DW
yarn add cz-lerna-changelog -DW

commitizen used for formatting git commit message. It providers a interactive to retrieve commit message.
cz-lerna-changelog used for Lerna commit message specifically。

After install both pckages, we add config in root package.json. config cz-lerna-changelog to commitizen, mean time, we have to add scripts for commitizen because we save them in devDenpendenies. So we have to add scripts for excuting git-cz

1
2
3
4
5
6
7
8
9
10
 "scripts": {
...
"commit": "git-cz",
...
},
"config": {
"commitizen": {
"path": "./node_modules/cz-lerna-changelog"
}
},

At this time,we can use

1
yarn commit 

to commit message with elegent.

. commitlint && husky

We used commitizen above to standardize commits, but it’s up to the developer to use yarn commit. What if you forget, or commit directly with git commit? The answer is to check the commit information at commit time, and if it doesn’t meet the requirements, then you will not commit it and will be prompted. This is done by commitlint, and the timing is specified by husky. husky inherits all the hooks from Git, and when the hooks are triggered, husky can block illegal commits, pushes, and so on.

1
2
yarn add -DW @commitlint/cli @commitlint/config-conventional
yarn add -DW husky

Add husky config in root package.json

1
2
3
4
5
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
}

And add commitlint.config.js in root directory.

1
touch commitlint.config.js

with context:

1
module.exports = { extends: ['@commitlint/config-conventional'] }

commit-msg is a hook that checks the commit message when git commit is triggered, and will use commitlit to check it when triggered. If you want to commit via git commit or other third-party tools after installation and configuration, you will not be able to commit if the commit message does not match the specification. This constrains developers from using yarn commit for commits.

We can use

1
echo "build: change something" | yarn commitlint

to Vverify that your commit message passes the commitlint check or not.

. standardjs && lint-staged

1
yarn add -DW standard lint-staged
1
2
3
4
5
6
7
8
9
10
11
12
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"lint-staged": {
"*.js": [
"standard --fix",
"git add"
]
},

Add the lint-staged configuration to root package.json, as shown above, to perform standard --fix checksum on the js files in the staging area and repair them automatically. Then when to verify it, we use husky installed above again, husky’s configuration add pre-commit hook to perform lint-staged verification operation, as shown above.
At this time, when the js file is submitted, it will automatically correct and verify the errors. This ensures uniform code style and improves code quality.

5. npm publish locally:Verdaccio

1
2
3
yarn global add verdaccio
verdaccio
touch .npmrc
.npmrc content
1
registry="http://localhost:4873/"

It’s done! Whenever you run lerna publish, the package built from the child project will be published in the local npm repository, and when you run lerna bootstrap, Verdaccio will release it, allowing you to successfully pull the corresponding code from the remote npm repository.

5. Push this to github

New a repostory in github.com, and run commands below.

1
2
3
4
5
git remote add origin https://github.com/nateliu/best-practice-in-react.git
git branch -M main
git add .
git commit -m 'Initialize best-practice-in-react'
git push -u origin main

6. Import an existed repository into this monorepo

1
2
3
4
5
lerna import ../react-best-practice
lerna import ../react-component-library
lerna import ../webpack-react-startkit
lerna import ../webpack-redux-startkit
lerna import ../react-redux-startkit

Try to setup react soure code learning environment.

1. Step 1

1
2
3
4
5
git clone https://github.com/facebook/react.git
cd react
git branch -a
git checkout 17.0.2
yarn install

According the How to contribute, next run command below

1
2
3
4
5
6
7
8
9
yarn build react/index,react-dom/index --type=UMD

# open fixtures/packaging/babel-standalone/dev.html and it used the compiled react.js

cd build/node_modules/react
yarn link
cd build/node_modules/react-dom
yarn link

Using yarn create react-app to create a new project and remove dependency of react and react-dom in package.json

1
2
3
yarn create react-app react-source-learning --template:typescript
cd react-source-learning
yarn link react react-dom

2. Step 2

Write JSX in maunally.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React from 'react';
import ReactDOM from 'react-dom';

//jsx
const element = <h1 className='title' style={{color:'red'}}> hello</h1>

//babel=>js function. React.createElement()=>vnode

const element2 = React.createElement("h1", {
className: "title",
style: {
color: 'red'
}
}, " hello");

console.log(element);

ReactDOM.render(
element2,
document.getElementById('root')
);

We can go to Babel to check the element mentioned above. element is equal to element2.

The process is:
1, JSX will be transferd to React.createElement method by Babel
2, React.createElement is actually returned a virtual node.
3, React.Render will render the virtual node to realistic dom

So next to implement a createElement in react.js.

And then to create a new react-dom.js

Today we are going to learning React diffing algorithm and key in React. Let’s understand with below example.

1. Diffing algorithm

1
2
mkdir src/components/diff
touch src/components/diff/DiffPage.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import React, { FC, ReactElement, useEffect, useState } from 'react'

interface Person {
id: number,
name: string,
age: number
}

const DiffPage: FC = (): ReactElement => {
const [dataValue, setDataValue] = useState(new Date())
const [personList, setPersonList] = useState([
{ id: 1, name: 'Alex', age: 18 },
{ id: 2, name: 'Allen', age: 19 },
])

useEffect(() => {
setInterval(() => setDataValue(new Date()), 1000)
}, [])

const addOne = () => {
const person: Person = { id: personList.length + 1, name: `people ${personList.length}`, age: 20 + personList.length }
setPersonList([person, ...personList])
}

return (
<div>
<div className='diff'>
<h2>hello</h2>
<input type="text" />
<span>
now:{dataValue.toTimeString()}
<input type="text" />
</span>
</div>
<div className='key'>
<h2>Person Information List</h2>
<button onClick={addOne}>Add one people</button>
<h3>using index as key</h3>
<ul>
{
personList.map((personObj: Person, index: number) => {
return <li key={index}>{personObj.name}---{personObj.age}<input type="text" /></li>
})
}
</ul>
<hr />
<hr />
<h3>using id as key</h3>
<ul>
{
personList.map((personObj: Person) => {
return <li key={personObj.id}>{personObj.name}---{personObj.age}<input type="text" /></li>
})
}
</ul>
</div>
</div>
)
};

export default DiffPage;

2. DOM vs Virtual DOM

DOM stands for Document Object Model. It is the hierarchical representation of the web page(UI).
Virtual DOM is just a copy of the original DOM kept in the memory and synced with the real DOM by libraries such as ReactDOM.
Because DOM manipulation is very slower and expensive in terms of time complexity.

When there is a update in the virtual DOM, react compares the virtual DOM with a snapshot of the virtual DOM taken right before the update of the virtual DOM.

With the help of this comparison React figures out which components in the UI needs to be updated. This process is called diffing. The algorithm that is used for the diffing process is called as the diffing algorithm.

Above source code will render to

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<div>
<div class="diff">
<h2>hello</h2><input type="text"><span>now:21:58:08 GMT+0800 (China Standard Time)<input type="text"></span>
</div>
<div class="key">
<h2>Person Information List</h2><button>Add one people</button>
<h3>using index as key</h3>
<ul>
<li>Alex---18<input type="text"></li>
<li>Allen---19<input type="text"></li>
</ul>
<hr>
<hr>
<h3>using id as key</h3>
<ul>
<li>Alex---18<input type="text"></li>
<li>Allen---19<input type="text"></li>
</ul>
</div>
</div>
  • The span in diff class will change every 1 second, but another html element won’t change in this class section, that is the help from diffing algorithm.
  • The key section, if use index as key, it can’t get benifit from diffing because the index was changed and will force evey li to render it again and again. If use the id as key, the performace will revert, because the id is unique and stable for every li.
  • Frequent DOM manipulations are expensive.
  • Virtual DOM is a virtual representation of DOM in memory.
  • Virtual DOM is synced with real DOM with ReactDOM library. This process is called Reconciliation.
  • React compares the Virtual DOM and pre-updated Virtual DOM and only marks the sub-tree of components that are updated. This process is called diffing.
  • The algorithm behind diffing is called Diffing algorithm.React uses keys to avoid unnecessary re-renders.

3. React event parameters

Compare bind parameter in React.

1
2
mkdir src/components/event
touch src/components/event/ClickButton.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import React, { FC, ReactElement } from 'react'

const ClickButton: FC = (): ReactElement => {

const handleClick = (e: any) => {
console.log(`this is passed any parameter:${e}`);
console.log(`this is from e.id:${e.target.id}`);
console.log(`this is e.data:${e.currentTarget.getAttribute('data-xxx')}`);
}

// const handleClick2 = (e: string) => {
// console.log(`this is passed string parameter:${e}`);
// }

const handleClick3 = (e: any, id: string) => {
console.log(`this is passed id:${id}`);
console.log(`this is from e.id:${e.target.id}`);
console.log(`this is e.data:${e.currentTarget.getAttribute('data-xxx')}`);
}

return (
<div>
<button id="123" data-xxx="789" onClick={handleClick}>
Click me1
</button>

{/* cannot write like this: */}
{/* <button id="1234" data-xxx="7890" onClick={handleClick2('123')}>
Click me2
</button> */}


<button id="456" data-xxx="78900" onClick={(e) => handleClick3(e, '123456')}>
Click me3
</button>
</div>
)
}

export default ClickButton;

Learning React with hooks

1
2
3
4
5
6
7
8
9
10
yarn create react-app react-best-practice --template=typescript && cd react-best-practice
mkdir src/router
touch src/router/IndexRouter.tsx
yarn add react-router-dom

mkdir src/components
mkdir src/components/forwardRef
touch src/components/forwardRef/ParentPage.tsx
touch src/components/forwardRef/ChildInput.tsx
touch src/components/forwardRef/types.tsx

1. Parent component define a ref and pass it to Child componet, Child component is used React.forwardRef to a real InputWithLabel component.

  • types.tsx
    types.tsx
    1
    2
    3
    4
    5
    6
    7
    8
    export interface IChildInputProps {
    label: string
    }

    export interface IInputWithLabelProps {
    label: string,
    myRef: any
    }
  • ParentPage.tsx
    ParentPage.tsx
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    import React, { FC, ReactElement, useRef } from 'react';
    import ChildInput from './ChildInput';

    const ParentPage: FC = (): ReactElement => {
    const myRef = useRef<HTMLInputElement>();

    const handleClick = () => {
    const node = myRef.current;
    console.log(node?.value);
    node?.focus()
    };

    return (
    <div>
    <ChildInput label={"Child Name"} ref={myRef} />
    <button onClick={handleClick}>Click</button>
    </div>
    );
    }

    export default ParentPage;
  • ChildInput.tsx
    ChildInput.tsx
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    import React, { forwardRef, ReactElement, useState } from "react";
    import { IChildInputProps, IInputWithLabelProps } from "./types"


    const InputWithLabel = ({ label, myRef }: IInputWithLabelProps): ReactElement => {
    const [value, setValue] = useState("");
    const handleChange = (e: any) => {
    console.log(`message from child.value=${e.target.value}`)
    const value = e.target.value;
    setValue(value);
    };

    return (
    <div>
    <span>{label}:</span>
    <input type="text" ref={myRef} value={value} onChange={handleChange} />
    </div>
    );
    }

    const ChildInput = forwardRef(({ label }: IChildInputProps, ref: any) => (
    <InputWithLabel label={label} myRef={ref} />
    ));

    export default ChildInput;
  • IndexRouter.tsx
    IndexRouter.tsx
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    import React, { ReactElement } from 'react';
    import { BrowserRouter, Route, Routes } from 'react-router-dom';
    import ParentPage from '../components/forwardRef/ParentPage';

    const IndexRouter = (): ReactElement => {
    return (
    <BrowserRouter>
    <Routes>
    <Route path="/forwardRef" element={<ParentPage />} />
    </Routes>
    </BrowserRouter>
    )
    }

    export default IndexRouter;

    This method will export the whole ChildInput to ParentPage, for security reason, we just want export some functions to ParentPage, that we can use useImperativeHandler
    Otherwise, for example user can write following code in ParentPage. Do you really want user write onclick event in ParentPage if you are the owner of ChildInput?

    1
    2
    3
    4
    5
    6
    const handleClick = () => {
    const node = myRef.current;
    console.log(node?.value);
    node?.focus();
    node.onclick = function() { alert('have clicked me!') }
    };

2. Add innerRef and useImperativeHandle in real inner component InputWithLabel

  • update ChildInput.tsx
    ChildInput.tsx
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    import React, { forwardRef, ReactElement, useState, useRef, useImperativeHandle } from "react";
    ...
    useImperativeHandle(myRef, () => ({
    getValue,
    focus() {
    const node = innerRef.current;
    node?.focus();
    }
    }));

    const getValue = () => {
    return value;
    }
    ...
    <input type="text" ref={innerRef} value={value} onChange={handleChange} />

  • update ParentPage
    ParentPage
    1
    2
    3
    4
    5
    6
    7
    8
    const myRef = useRef<any>();

    const handleClick = () => {
    const node = myRef.current;
    console.log(node);
    console.log(`the value '${node.getValue()}' from Child fucntion getValue()`);
    node.focus();
    };

    From the console, when click the Click button, we can see only getValue and focus were exported.

3. Delete InputWithLabel and move the content into ChildInput

  • Delete IInputWithLabelProps
    types.tsx
    1
    2
    3
    export interface IChildInputProps {
    label: string
    }
  • Update ChildInput.tsx
    ChildInput.tsx
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    import React, { forwardRef, ReactElement, useState, useRef, useImperativeHandle } from "react";
    import { IChildInputProps } from "./types"

    const ChildInput = ({ label }: IChildInputProps, ref: any): ReactElement => {
    const [value, setValue] = useState("");

    const innerRef = useRef<HTMLInputElement>(null);

    useImperativeHandle(ref, () => ({
    getValue,
    focus() {
    const node = innerRef.current;
    node?.focus();
    }
    }));

    const getValue = () => {
    return value;
    }

    const handleChange = (e: any) => {
    e.preventDefault();
    console.log(`message from child.value=${e.target.value}`)
    const value = e.target.value;
    setValue(value);
    };

    return (
    <div>
    <span>{label}:</span>
    <input type="text" ref={innerRef} value={value} onChange={handleChange} />
    </div>
    );
    }

    export default forwardRef(ChildInput);
  • No change in ParentPage

Use react hook to create a Todo app, and then migrate to typescript.

1. create todo-app-js

I am going to perform below componets for the app, and I am going to use hooks for implement. It will contain useState, useReducer, useEffect, useCallback

  • App
  • TodoList
  • TodoInput
  • TodoItem
  • todoReducer
1
2
3
4
5
6
7
8
9
yarn create react-app todo-app-js
cd todo-app-js
mkdir src/components
touch src/components/TodoList.js
touch src/components/TodoInput.js
touch src/components/TodoItem.js

mkdir src/reducer
touch src/reducer/todoReducer.js
TodoList
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import React, { useReducer, useEffect, useCallback } from 'react'
import TodoInput from './TodoInput'
import TodoItem from './TodoItem'
import todoReducer from '../reducer/todoReducer'

// comment out below line because has changed to use lazy load.
// const initialState:IState = {
// todoList:[]
// }

function init(initTodoList) {
return {
todoList: initTodoList
}
}

export default function TodoList() {
// comment out below line because has changed to use useReducer
// const [todoList,setTodoList] = useState([]);
const [state, dispatch] = useReducer(todoReducer, [], init)

useEffect(() => {
// console.log(state.todoList)
const list = localStorage.getItem('todoList' || '[]');
if (list) {
const todoLists = JSON.parse(list);
dispatch({
type: "INIT_TODOLIST",
payload: todoLists
})
}
}, [])

useEffect(() => {
localStorage.setItem('todoList', JSON.stringify(state.todoList))
}, [state.todoList])

const addTodo = useCallback(todo => {
// comment out below line because has changed to use useReducer.
// setTodoList(todoList => [...todoList,todo]);
dispatch({
type: "ADD_TODO",
payload: todo
})
}, [])


const removeTodo = useCallback((id) => {
dispatch({
type: "REMOVE_TODO",
payload: id
})
}, [])


const toggleTodo = useCallback((id) => {
dispatch({
type: "TOGGLE_TODO",
payload: id
})
}, [])

return (
<div>
<TodoInput
addTodo={addTodo}
todoList = {state.todoList}
/>
{
state.todoList &&
state.todoList.map(todo => {
return <TodoItem
key={todo.id}
todo={todo}
removeTodo={removeTodo}
toggleTodo={toggleTodo}
/>
})
}
</div>
)
}

TodoInput
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import React, { useRef } from 'react'

export default function TodoInput(props) {
const inputRef = useRef(null)
const handleClick = ()=> {
const val = inputRef.current.value.trim();

if(val.length){
const isExist = props.todoList.find(todo => todo.content === val);
if(isExist){
alert('already exist!');
return;
}

props.addTodo({
id: Date.now(),
content:inputRef.current.value.trim(),
completed:false
})

inputRef.current.value = '';
}


}
return (
<div>
<input type='text' ref={inputRef} />
<button onClick={()=>handleClick()}>Add</button>
</div>
)
}

TodoItem
1
2
3
4
5
6
7
8
9
10
11
12
13
import React from 'react'

export default function TodoItem(props) {
const { id, content, completed } = props.todo;
return (
<div>
<input type="checkbox" checked={completed} onChange={() => props.toggleTodo(id)} />
<span style={{ textDecoration: completed ? 'line-through' : 'none' }}>{content}</span>
<button onClick={() => props.removeTodo(id)}>Delete</button>
</div>
)
}

todoReducer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
export default function todoReducer(state, action) {
const { type, payload } = action;
switch (type) {
case "INIT_TODOLIST":
return {
...state,
todoList: payload
}
case "ADD_TODO":
return {
...state,
todoList: [...state.todoList, payload]
}
case "REMOVE_TODO":
return {
...state,
todoList: state.todoList.filter(todo => todo.id !== payload)
}
case "TOGGLE_TODO":
return {
...state,
todoList: state.todoList.map(todo => {
return todo.id === payload ? {
...todo,
completed: !todo.completed
} : {
...todo
}
})
}
default:
return state;
}
}

!! Here I chose useReducer to instead of useState. It looks unnecessary, but if we add a new component as middle component between TodoList and TodoItem, then the useReducer is better than useState.

Pretty easy! right? that is javascript version, because I am a Java progromer before, I feel it is weird. I can give any parameters to our function in the writing duration. For example. How do I know action has type and payload if I just in todoReducer file?If I typo the paylaod, it won’t give any error in the writing duration. So next I am going to change to use TypeScript

2. create todo-app-ts

The components are the same with todo-app-js

1
2
3
4
5
6
7
8
9
yarn create react-app todo-app-ts --template=typescript
cd todo-app-ts
mkdir src/components
touch src/components/TodoList.tsx
touch src/components/TodoInput.tsx
touch src/components/TodoItem.tsx

mkdir src/reducer
touch src/reducer/todoReducer.ts

!! use --template=typescript to tell yarn create to create a typescript application.

  • Create a interfact as types.

    1
    2
    mkdir src/types
    touch src/types/todoTypes.ts
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    export interface ITodo{
    id: number,
    content: string,
    completed:boolean
    }

    export interface IState{
    todoList: ITodo[]
    }

    export interface IAction{
    type: ACTION_TYPE,
    payload: ITodo | ITodo[] | number //payload can be array and number.
    }

    export enum ACTION_TYPE{
    ADD_TODO = 'addTodo',
    REMOVE_TODO = 'removeTodo',
    TOGGLE_TODO = 'toggleTodo',
    INIT_TODOLIST = 'initTodoList'
    }
  • Go to todoReducer.ts

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    import { ACTION_TYPE, IAction, IState, ITodo } from '../types/todoTypes';

    export default function todoReducer(state: IState, action: IAction): IState {
    const { type, payload } = action;

    switch (type) {
    case ACTION_TYPE.INIT_TODOLIST:
    return {
    ...state,
    todoList: payload as ITodo[]
    }
    case ACTION_TYPE.ADD_TODO:
    return {
    ...state,
    //payload as ITodo is important, otherwise typescript don't know it is todo or id.
    todoList: [...state.todoList, payload as ITodo]
    }
    case ACTION_TYPE.REMOVE_TODO:
    return {
    ...state,
    todoList: state.todoList.filter(todo => todo.id !== payload)
    }
    case ACTION_TYPE.TOGGLE_TODO:
    return {
    ...state,
    todoList: state.todoList.map(todo => {
    return todo.id === payload ? {
    ...todo,
    completed: !todo.completed
    } : {
    ...todo
    }
    })
    }
    default:
    return state;
    }
    }
  • Evey each component should return ReactElement. So we are not use rfc formatting, we are change to below formation as well

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    interface IProps{
    ...
    }
    const ComponentName:FC<IProps> = ({...Iprops...}) => {
    ....
    return (
    <div>
    ComponentName
    </div>
    );
    }

    export default ComponentName;

    Here we don’t need move the IProps to src/types/ folder, because here the IProps is a spicific parameter for this component, not a common parameter. For example below TodoList doesn’t have Props and its has no IProps interfacte.

TodoList.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
import React, { ReactElement, useReducer, useEffect, useCallback } from 'react'
import todoReducer from '../reducer/todoReducer';
import { ACTION_TYPE, IState, ITodo } from '../types/todoTypes';
import TodoInput from './TodoInput';
import TodoItem from './TodoItem';

// comment out below line because has changed to use lazy load.
// const initialState:IState = {
// todoList:[]
// }

function init(initTodoList: ITodo[]):IState{
return {
todoList: initTodoList
}
}

const TodoList: FC = (): ReactElement => {
// comment out below line because has changed to use useReducer
// const [todoList, setTodoList] = useState<ITodo[]>([]);

// comment out below line because has changed to use lazy load.
// const [ state, dispatch] = useReducer(todoReducer,initialState)

// lazy load
const [state, dispatch] = useReducer(todoReducer, [], init)

useEffect(() => {
// console.log(state.todoList)
const list = localStorage.getItem('todoList' || '[]');
if (list) {
const todoLists = JSON.parse(list);
dispatch({
type: ACTION_TYPE.INIT_TODOLIST,
payload: todoLists
})
}
}, [])

useEffect(() => {
localStorage.setItem('todoList', JSON.stringify(state.todoList))
}, [state.todoList])


const addTodo = useCallback((todo: ITodo) => {
// comment out below line because has changed to use useReducer
// setTodoList(todoList => [...todoList,todo]);
dispatch({
type: ACTION_TYPE.ADD_TODO,
payload: todo
})
}, [])

const removeTodo = useCallback((id: number) => {
dispatch({
type: ACTION_TYPE.REMOVE_TODO,
payload: id
})
}, [])

const toggleTodo = useCallback((id: number) => {
dispatch({
type: ACTION_TYPE.TOGGLE_TODO,
payload: id
})
}, [])

return (
<div className="todo-list">
<TodoInput
addTodo={addTodo}
todoList={state.todoList}
/>
{
state.todoList &&
state.todoList.map(todo => {
return <TodoItem
key={todo.id}
todo={todo}
removeTodo={removeTodo}
toggleTodo={toggleTodo}
/>
})
}
</div>
)
}
export default TodoList;

  • TodoList did all the logics, we assume TodoInput and TodoItem are child components.

    TodoInput
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    import React, { FC, ReactElement, useRef } from 'react'
    import { ITodo } from '../types/todoTypes'

    interface IProps {
    addTodo: (todo: ITodo) => void,
    todoList: ITodo[]
    }

    const TodoInput: FC<IProps> = ({ addTodo, todoList }): ReactElement => {

    const inputRef = useRef<HTMLInputElement>(null);

    const addItem = (): void => {

    const val: string = inputRef.current!.value.trim();

    if (val.length) {
    const isExist = todoList.find(todo => todo.content === val);
    if (isExist) {
    alert('already exist!');
    return;
    }

    addTodo({
    id: new Date().getTime(),
    content: val,
    completed: false
    })

    inputRef.current!.value = '';
    }
    }

    return (
    <div>
    <input type="text" ref={inputRef} />
    <button onClick={addItem}>Add</button>
    </div>
    )
    }

    export default TodoInput

    Because we have pointed the props.addTodo as addTodo in the definition. So here we can just use addTodo.

  • Go to TodoItem

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    import React, { FC, ReactElement } from 'react'
    import { ITodo } from '../types/todoTypes'

    interface IProps {
    todo: ITodo,
    toggleTodo: (id: number) => void,
    removeTodo: (id: number) => void
    }

    const TodoItem: FC<IProps> = ({ todo, toggleTodo, removeTodo }): ReactElement => {
    const { id, content, completed } = todo;

    return (
    <div>
    <input type="checkbox" checked={completed} onChange={() => toggleTodo(id)} />
    <span style={{ textDecoration: completed ? 'line-through' : 'none' }}>{content}</span>
    <button onClick={() => removeTodo(id)}>Delete</button>
    </div>
    )
    }
    export default TodoItem;

3. setup environtment from 0

  • Initialize todo-app-self
    1
    2
    3
    mkdir todo-app-self 
    cd todo-app-self
    yarn init
1
2
3
4
5
6
7
8
{
"name": "todo-app-self",
"version": "1.0.0",
"description": "source of typescript",
"main": "src/index.tsx",
"author": "Nate Liu",
"license": "MIT"
}

If use yarn init -y then the package.json will without author and description.

1
2
3
4
5
6
7
8
9
10
mkdir src
touch src/index.ts
mkdir src/components
touch src/components/TodoList.tsx
touch src/components/TodoInput.tsx
touch src/components/TodoItem.tsx

mkdir src/reducer
touch src/reducer/todoReducer.ts

  • Add typescript and tslint globally
    1
    2
    yarn global add typescript tslint
    tsc --init
    this will generate a tsconfig.json in the root projct.

    !!yarn global add xxx is not equal yarn add global xxx

    1
    2
    3
    yarn add webpack webpack-cli webpack-dev-server
    mkdir build
    touch build/webpack.config.js
    webpack.config.js
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    cconst path = require('path')
    const HtmlWebpackPlugin = require('html-webpack-plugin')
    const { CleanWebpackPlugin } = require('clean-webpack-plugin')

    module.exports = {
    entry: './src/index.tsx',
    output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist'),
    },
    resolve: {
    extensions: ['.ts', '.tsx', '.js']
    },
    module: {
    rules: [
    {
    test: /\.tsx?$/,
    use: 'ts-loader',
    exclude: /node_modules/
    }
    ]
    },
    devtool: process.env.NODE_ENV === 'production' ? false : 'inline-source-map',
    devServer: {
    client: {
    overlay: {
    errors: true,
    warnings: false,
    },
    },
    static: path.join(__dirname, 'dist'),
    hot: true,
    compress: false,
    host: 'localhost',
    port: 3000
    },
    plugins: [
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
    template: './src/template/index.html'
    })
    ]
    }
    According to this webpack.config.js,we also need to add files as below:
    1
    2
    3
    4
    5
    6
    yarn add path html-webpack-plugin clean-webpack-plugin ts-loader

    mkdir src/template
    touch src/template/index.html

    yarn add typescript
    template file content is
    index.html
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Todo-App-Self</title>
    </head>
    <body>
    <div id="root"></div>
    </body>
    </html>

Go back package.json, and add scripts

1
2
3
4
5
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "cross-env NODE_ENV=development webpack-dev-server --config ./build/webpack.config.js",
"build": "cross-env NODE_ENV=production webpack --config ./build/webpack.config.js"
}

Here we used cross-env, so

1
yarn add cross-env

Go to index.tsx

1
console.log('Hello, Webpack')

by now, when run below command, you will see Hello, Webpack in console.

1
yarn start

Because we need react, it also need add following libraries.

1
yarn add react react-dom @types/react @types/react-dom

The package.json looks like as below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
"name": "todo-app-self",
"version": "1.0.0",
"description": "source of typescript",
"main": "src/index.tsx",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "cross-env NODE_ENV=development webpack-dev-server --config ./build/webpack.config.js",
"build": "cross-env NODE_ENV=production webpack --config ./build/webpack.config.js"
},
"author": "Nate Liu",
"license": "MIT",
"dependencies": {
"@types/react": "^17.0.38",
"@types/react-dom": "^17.0.11",
"clean-webpack-plugin": "^4.0.0",
"cross-env": "^7.0.3",
"html-webpack-plugin": "^5.5.0",
"path": "^0.12.7",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"ts-loader": "^9.2.6",
"typescript": "^4.5.4",
"webpack": "^5.65.0",
"webpack-cli": "^4.9.1",
"webpack-dev-server": "^4.7.2"
}
}

Most of content in tsconfig.json doesn’t need to modify, but only one jsx from preserve to react

1
"jsx": "react", 

In my experience, it looks like

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */

/* Projects */
// "incremental": true, /* Enable incremental compilation */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */

/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
"jsx": "react", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */

/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "resolveJsonModule": true, /* Enable importing .json files */
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */

/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */

/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */

/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */

/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */

/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

webpack official site is here, if has any problem, the better thing is to visit official site to get guideline.

  • Copy todo-app-ts files to this project. and it will has same result.

Will update and fix issue in this document.

Update Section

1. Change HashRouter to BrowserRouter

In previous, the NPM uses HashRouter, it is fine, but has # in the Browser, If you don’t like the # in urls like me, then please change to BrowserRouter. For changing to BrowserRouter, every should remove # from href property. I guess this is the biggest different bewteen HashRouter and BrowserRouter.

Fixing Section

1. How to fix “React Hook useEffect has a missing dependency. Either include it or remove the dependency array” problem?

I have a code-snippet in Home.js

1
2
3
4
5
6
7
8
9
10
11
useEffect(() => {
axios.get(`/api/news?publisthState=2&_expand=category`).then(res => {
setAllList(res.data);
const viewData = _.groupBy(res.data, item => item.category.title);
renderBarView(viewData)
})

return () => {
window.onresize = null;
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps

If I did’t add last line of the comment, it will occcure a warning messge. That because the renderBarView is a function. The hook useEffect cann’t do like this, it will run on every re-render and will give a loop of re-render error. So the solution to resolve this waning are 2 options: one is above I did. Another is copy the renderBarView into useEffect. like below

1
2
3
4
const renderBarView = vieewData => {
....
}

In Loign components, after clicked Sign In button, it had executed the navigate(‘/‘), but didn’t redirect.

The root cause is navigate has 2 signature, if use like above, it is must required NavigateOptions. So change to below code can fix this issue.

1
navigate('/', {replace:true});

3. Audit usage of navigator.userAgent, navigator.appVersion, and navigator.platform

Found there are some codes like below:

// Mock not supported chrome.* API for Firefox and Electron
window.isElectron = window.navigator && window.navigator.userAgent.indexOf('Electron') !== -1;
var isFirefox = navigator.userAgent.indexOf('Firefox') !== -1; // Background page only

definitely, they are from node_modules but not sure which library. So it wont fix right now. Detail message can be found in here

…To be continue….

I am going to localize the news-publish-management with react-i18next.

1. config react-i18next

1
2
3
4
5
yarn add react-i18next i18next
mkdir src/i18n
touch src/i18n/config.js
touch src/i18n/en.json
touch src/i18n/zh.json

content in config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';

import translation_en from './en.json';
import translation_zh from './zh.json';

const resources = {
en: {
translation: translation_en,
},
zh: {
translation: translation_zh,
},
};

i18n.use(initReactI18next).init({
resources,
lng: 'zh',
interpolation: {
escapeValue: false,
},
});

export default i18n;

en.json add content

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"Login": {
"title": "News Publish Management",
"signin":"Sign In",
"validateUserName": "Please input your Username!",
"validatePassword": "Please input your Password!",
"validateLanguage": "Please select your Language!",
"validateErrorMessage": "UserName or Password is wrong!"
},
"TopHeader": {
"languageTitle": "Language",
"welcomeMessage": "Welcome",
"signout":"Sign Out"
}
}

zh.json add content

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"Login": {
"title": "全球新闻发布系统",
"signin":"登陆",
"validateUserName": "请输入正确用户名!",
"validatePassword": "请输入正确的密码!",
"validateLanguage": "请选择语言!",
"validateErrorMessage": "用户名或密码不正确!"
},
"TopHeader": {
"languageTitle": "语言",
"welcomeMessage": "欢迎",
"signout":"退出"
}
}

import config in index.js

1
2
3
...
import './i18n/config';
...

2. Using useTranslation in Login component

Go to Login.js, import i18n and use useTranslation/Trans Hoc to do translate.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { useTranslation, Trans } from 'react-i18next';
import i18n from 'i18next';
...
const [language, setLanguage] = useState('zh');
const { t } = useTranslation();
...

message.error(t('Login.validateErrorMessage'))
...
<div className='loginTitle'> <Trans>login.title</Trans></div>
...

// support user to select his prefer language

<Form.Item
name="language"
rules={[{ required: true, message: t('Login.validateLanguage') }]}
>
<Select value={language} onChange={handleLanguageChange} placeholder={<GlobalOutlined className="site-form-item-icon" />} >
<Option value='zh'>中文</Option>
<Option value='en'>English</Option>
</Select>
</Form.Item>
...
const handleLanguageChange = value => {
// console.log(`selected ${value}`);
setLanguage(value);
i18n.changeLanguage(value);
}

3. Localize in TopHeader component

Just use useTranslation hook in this component.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
...
import { useTranslation } from 'react-i18next';
import i18n from 'i18next';
...
const { t } = useTranslation();
const handleLanguageClick = value => {
i18n.changeLanguage(value);
}
...

<Menu.SubMenu key="lngMenu" icon={<GlobalOutlined />} title={t('TopHeader.languageTitle')}>
<Menu.Item key="lngMenuZH" onClick={() => handleLanguageClick('zh')}>中文</Menu.Item>
<Menu.Item key="lngMenuEN" onClick={() => handleLanguageClick('en')}>English</Menu.Item>
</Menu.SubMenu>
<Menu.Item danger key='loginOutMenu' onClick={() => {
localStorage.removeItem('token');
navigate('/login');
}}>{t('TopHeader.signout')}</Menu.Item>

This section we are going to finish the guest system.

1. create a guest system inforstructor.

1
2
3
mkdir src/views/news
touch src/views/news/News.js
touch src/views/news/Detail.js

rfc create those 2 components. Go to IndexRouter.js. add 2 routes.

1
2
3
4
5
import Detail from '../views/news/Detail'
import News from '../views/news/News'
...
<Route path="/news" element={<News />} />
<Route path="/detail/:id" element={<Detail />} />

2. News component

we use PageHeader Basic and Card Basic and List Simple to perform this feature.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import { PageHeader, Card, Col, Row, List } from 'antd';
import React, { useEffect, useState } from 'react';
import _ from 'lodash';
import axios from 'axios';

export default function News() {
const [list, setList] = useState([]);
useEffect(() => {
axios.get(`/api/news?publisthState=2&_expand=category`).then(res => {
const twoDims = Object.entries(_.groupBy(res.data, item => item.category.title));
// console.log(twoDims);
setList(twoDims);
})
}, []);

return (
<div style={{
width: "95%",
margin: "0 auto"
}}>
<PageHeader
className="site-page-header"
title="全球大新闻"
subTitle="查看新闻"
/>
<div className="site-card-wrapper">
<Row gutter={[16, 16]}>
{
list.map(item => {
return <Col span={8} key={item[0]}>
<Card title={item[0]} bordered={true} hoverable={true}>
<List
size="small"
dataSource={item[1]}
pagination={{
pageSize: 3
}}
renderItem={data => <List.Item>
<a href={`#/detail/${data.id}`}>{data.title}</a>
</List.Item>}
/>
</Card>
</Col>
})
}
</Row>
</div>
</div>
)
}

3. Detail component

copy from src/views/sandbox/news-manage/NewsPreviews.js and modify as below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import { HeartTwoTone } from '@ant-design/icons';
import { PageHeader, Descriptions } from 'antd'
import axios from 'axios';
import moment from 'moment';
import React, { useEffect, useState } from 'react'
import { useParams } from 'react-router';

export default function Detail() {
const [newsInfo, setNewsInfo] = useState(null);

const params = useParams();

const handleStar = () => {
setNewsInfo({
...newsInfo,
star: newsInfo.star + 1
});

axios.patch(`/api/news/${params.id}`, {
star: newsInfo.star + 1
})
}

useEffect(() => {
// console.log(props.match.params.id)
axios.get(`/api/news/${params.id}?_expand=category&_expand=role`).then(res => {
// console.log(res.data);
setNewsInfo({
...res.data,
view: res.data.view + 1
});

return res.data;
}).then(data => {
axios.patch(`/api/news/${params.id}`, {
view: data.view + 1
})
})
}, [params.id]);

return (
newsInfo && <div>
<PageHeader
onBack={() => window.history.back()}
title={newsInfo.title}
subTitle={
<div>
{newsInfo.category?.title}
<HeartTwoTone twoToneColor="#eb2f96" onClick={() => handleStar()} />
</div>
}>
<Descriptions size="small" column={3}>
<Descriptions.Item label="创建者">{newsInfo.author}</Descriptions.Item>
<Descriptions.Item label="发布时间">{newsInfo.publishTime ? moment(newsInfo.publishTime).format("YYYY/MM/DD HH:mm:ss") : "-"}</Descriptions.Item>
<Descriptions.Item label="区域">{newsInfo.region}</Descriptions.Item>
<Descriptions.Item label="访问数量">{newsInfo.view}</Descriptions.Item>
<Descriptions.Item label="点赞数量">{newsInfo.star}</Descriptions.Item>
<Descriptions.Item label="评论数量">0</Descriptions.Item>
</Descriptions>
</PageHeader>
<div dangerouslySetInnerHTML={{
__html: newsInfo.content
}} style={{
margin: "0 24px",
border: "1px solid gray"
}}>
</div>
</div>
)
}

We will use Card Basic and List Simple to perform a dashboard in NPM system.

1. Go to Home.js

src/views/sandbox/home/Home.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import { Card, Col, Row, List, Avatar } from 'antd'
import React, { useEffect, useState } from 'react'
import { EditOutlined, EllipsisOutlined, SettingOutlined } from '@ant-design/icons';
import axios from 'axios';

const { Meta } = Card;

export default function Home() {
const [viewList, setViewList] = useState([]);
const [starList, setStarList] = useState([]);

const { username, region, role: { roleName } } = JSON.parse(localStorage.getItem('token'));

useEffect(() => {
axios.get(`/api/news?publisthState=2&_expand=category&_sort=view&_order=desc&_limit=6`).then(res => {
// console.log(res.data);
setViewList(res.data);
})
}, []);

useEffect(() => {
axios.get(`/api/news?publisthState=2&_expand=category&_sort=star&_order=desc&_limit=6`).then(res => {
// console.log(res.data);
setViewList(res.data);
})
}, []);

return (
<div className="site-card-wrapper">
<Row gutter={16}>
<Col span={8}>
<Card title="用户最常浏览" bordered={true}>
<List
size="small"
dataSource={viewList}
renderItem={item => <List.Item>
<a href={`#/news-manage/preview/${item.id}`}>{item.title}</a>
</List.Item>}
/>
</Card>
</Col>
<Col span={8}>
<Card title="用户点赞最多" bordered={true}>
<List
size="small"
dataSource={starList}
renderItem={item => <List.Item>
<a href={`#/news-manage/preview/${item.id}`}>{item.title}</a>
</List.Item>}
/>
</Card>
</Col>
<Col span={8}>
<Card
cover={
<img
alt="example"
src="https://gw.alipayobjects.com/zos/rmsportal/JiqGstEfoWAOHiTxclqi.png"
/>
}
actions={[
<SettingOutlined key="setting" />,
<EditOutlined key="edit" />,
<EllipsisOutlined key="ellipsis" />,
]}
>
<Meta
avatar={<Avatar src="https://joeschmoe.io/api/v1/random" />}
title={username}
description={
<div>
<b>{region ? region : 'Global'}</b>
<span style={{ paddingLeft: "30px" }}>{roleName}</span>
</div>
}
/>
</Card>
</Col>
</Row>
</div>
)
}

2. Add bar/pie charts

We chose Echarts to peform this feature.

1
yarn add echarts

Use lodash for manipulating data in front end

1
yarn add lodash

add bar chart

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import React, { useEffect, useRef, useState } from 'react'
...
const barRef = useRef()
...
useEffect(() => {
axios.get(`/api/news?publisthState=2&_expand=category`).then(res => {
const viewData = _.groupBy(res.data, item => item.category.title);
// console.log(viewData);
renderBarView(viewData)
})

return ()=>{
window.onresize = null;
}
}, []);

const renderBarView = (obj) => {
const option = {
title: {
text: '新闻分类图示'
},
tooltip: {},
legend: {
data: ['数量']
},
xAxis: {
data: Object.keys(obj),
axisLabel: {
rotate: "45",
interval: 0
}
},
yAxis: {
minInterval:1
},
series: [
{
name: '数量',
type: 'bar',
data: Object.values(obj).map(item => item.length)
}
]
};

const myChart = ECharts.init(barRef.current);

myChart.setOption(option);

window.onresize = () =>{
myChart.resize();
}
}
...

<div ref={barRef} style={{
width: '100%',
height: '400px',
marginTop: '30px',
}}></div>
...

add pie chart
we use Drawer Custom Placeholder for popup. It is similar to bar chart.

3. The Home.js looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
import { Card, Col, Row, List, Avatar, Drawer } from 'antd'
import React, { useEffect, useRef, useState } from 'react'
import { EditOutlined, EllipsisOutlined, SettingOutlined } from '@ant-design/icons';
import axios from 'axios';
import * as ECharts from 'echarts';
import _ from 'lodash';

const { Meta } = Card;

export default function Home() {
const [viewList, setViewList] = useState([]);
const [starList, setStarList] = useState([]);
const [allList, setAllList] = useState([]);
const [drawerVisible, setDrawerVisible] = useState(false);
const [barChart, setBarChart] = useState(null);
const [pieChart, setPieChart] = useState(null);

const barRef = useRef();
const pieRef = useRef();

const { username, region, role: { roleName } } = JSON.parse(localStorage.getItem('token'));

useEffect(() => {
axios.get(`/api/news?publisthState=2&_expand=category&_sort=view&_order=desc&_limit=6`).then(res => {
// console.log(res.data);
setViewList(res.data);
})
}, []);

useEffect(() => {
axios.get(`/api/news?publisthState=2&_expand=category&_sort=star&_order=desc&_limit=6`).then(res => {
// console.log(res.data);
setViewList(res.data);
})
}, []);

useEffect(() => {
axios.get(`/api/news?publisthState=2&_expand=category`).then(res => {
setAllList(res.data);
const viewData = _.groupBy(res.data, item => item.category.title);
// console.log(viewData);
renderBarView(viewData)
})

return () => {
window.onresize = null;
}
}, []);

const renderBarView = (obj) => {
const option = {
title: {
text: '新闻分类图示'
},
tooltip: {},
legend: {
data: ['数量']
},
xAxis: {
data: Object.keys(obj),
axisLabel: {
rotate: "45",
interval: 0
}
},
yAxis: {
minInterval: 1
},
series: [
{
name: '数量',
type: 'bar',
data: Object.values(obj).map(item => item.length)
}
]
};

let myChart;

if (!barChart) {
myChart = ECharts.init(barRef.current);
setBarChart(myChart)
} else {
myChart = barChart;
}

myChart.setOption(option);

window.onresize = () => {
myChart.resize();
}
}

const renderPieView = (obj) => {
const currentList = allList.filter(item => item.author === username);
// console.log(currentList);
const groupObj = _.groupBy(currentList, item => item.category.title);
const list = [];
for (var i in groupObj) {
list.push({
name: i,
value: groupObj[i].length
})
}

const option = {
title: {
text: `当前用户${username}的新闻分类图示`,
left: 'center'
},
tooltip: {
trigger: 'item'
},
legend: {
orient: 'vertical',
left: 'left'
},
series: [
{
name: '发布数量',
type: 'pie',
radius: '50%',
data: list,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
};

let myChart
if (!pieChart) {
myChart = ECharts.init(pieRef.current);
setPieChart(myChart);
} else {
myChart = pieChart;
}

option && myChart.setOption(option);
}

return (
<div className="site-card-wrapper">
<Row gutter={16}>
<Col span={8}>
<Card title="用户最常浏览" bordered={true}>
<List
size="small"
dataSource={viewList}
renderItem={item => <List.Item>
<a href={`#/news-manage/preview/${item.id}`}>{item.title}</a>
</List.Item>}
/>
</Card>
</Col>
<Col span={8}>
<Card title="用户点赞最多" bordered={true}>
<List
size="small"
dataSource={starList}
renderItem={item => <List.Item>
<a href={`#/news-manage/preview/${item.id}`}>{item.title}</a>
</List.Item>}
/>
</Card>
</Col>
<Col span={8}>
<Card
cover={
<img
alt="example"
src="https://gw.alipayobjects.com/zos/rmsportal/JiqGstEfoWAOHiTxclqi.png"
/>
}
actions={[
<SettingOutlined key="setting" onClick={() => {
setTimeout(() => {
setDrawerVisible(true);
renderPieView();
}, 0);

}} />,
<EditOutlined key="edit" />,
<EllipsisOutlined key="ellipsis" />,
]}
>
<Meta
avatar={<Avatar src="https://joeschmoe.io/api/v1/random" />}
title={username}
description={
<div>
<b>{region ? region : 'Global'}</b>
<span style={{ paddingLeft: "30px" }}>{roleName}</span>
</div>
}
/>
</Card>
</Col>
</Row>

<div ref={barRef} style={{
width: '100%',
height: '400px',
marginTop: '30px',
}}></div>

<Drawer title="个人新闻分类"
width='500px'
placement="right"
closable={true}
onClose={() => {
setDrawerVisible(false)
}}
visible={drawerVisible}>
<div ref={pieRef} style={{
width: '100%',
height: '400px',
marginTop: '30px',
}}></div>
</Drawer>
</div>
)
}

Next we focus on the state management, here we use classic Redux.

1. setup redux

1
2
3
4
5
yarn add redux react-redux
mkdir src/redux
touch src/redux/store.js
mkdir src/redux/reducers
touch src/redux/reducers/CollapsedReducer.js

redux principles
. Single source of truth
. State is read-only
. Changes are made with pure functions

2. Finish the collapsed in both TopHeader and SideMenu components.

CollapsedReducer.js

1
2
3
export const CollapsedReducer = (prevState = { isCollapsed: false }, action) => {
return prevState;
}
1
2
3
4
5
6
7
8
9
10
import { createStore, combineReducers } from 'redux';
import { CollapsedReducer } from './reducers/CollapsedReducer';

const reducer = combineReducers({
CollapsedReducer
})

const store = createStore(reducer);

export default store;

we have a single store now, it mean if we can get back our store, then we can do store.dispatch or store.subscribe operations. but how to get store in our component?
Go to App.js, make our IndexRouter in the Provider section.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import React from 'react'
import IndexRouter from './router/IndexRouter'
import { Provider } from 'react-redux'
import store from './redux/store'

import './App.css'

export default function App() {
return (
<Provider store={store}>
<IndexRouter />
</Provider>
)
}

Our SideMenu and TopHeader will use the store. so go to TopHeader first, because Topheader is publish the state.Its response is Dispatch the state to store.
In TopHeader, we use connect to make TopHeader as child components, we can get value from parameter of props.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { connect } from 'react-redux';
...

function TopHeader(props) {
...
}

const mapStateToProps = () => {
return {
testValue:'testValue',
}
}

export default connect(mapStateToProps)(TopHeader);

In previous version, we use component’s state, now we have props. and props contain isCollapsed, so we remove the component state.

in TopHeader
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import React from 'react'
...
import { connect } from 'react-redux';
...

function TopHeader(props) {
// NOTE: changed to redux, state is not neccessary.
// const [collapsed, setCollapsed] = useState(false)
...
const changeCollapsed = () => {
// NOTE: changed to redux, state is not neccessary.
// setCollapsed(!collapsed);
props.changeCollapsed();
}
}


const mapStateToProps = ({ CollapsedReducer: { isCollapsed } }) => {
return {
isCollapsed
}
}

const mapDispatchToProps = {
changeCollapsed() {
return {
type: "change_collapsed",
// payload:''
}
}
}

export default connect(mapStateToProps,mapDispatchToProps)(TopHeader);

when user click the icon in TopHeader, the workflow go to store, and store find out the CollapsedReducer, then excute the logic to change to new state. below are the CollapsedReducer.js content:

1
2
3
4
5
6
7
8
9
10
11
12
export const CollapsedReducer = (prevState = { isCollapsed: false }, action) => {
// console.log(action);
const { type } = action;
switch (type) {
case 'change_collapsed':
let newState = { ...prevState };
newState.isCollapsed = !newState.isCollapsed;
return newState;
default:
return prevState;
}
}

Back to SideMedu, SideMenu retrieve the state to collapse or expand himself. We also connect SideMenu first. It doesn’t need Dispatch anything, so our changing in SideMenu looks like:

1
2
3
4
5
6
7
8
9
10
import { connect } from 'react-redux';
...
function SideMenu(props) {
...
<Sider trigger={null} collapsible collapsed={props.isCollapsed}>
}

const mapStateToProps = ({ CollapsedReducer: { isCollapsed } }) => ({ isCollapsed });

export default connect(mapStateToProps)(SideMenu);

3. Add spin when retrieve data from backend db.

Go to NewsRouter.js

1
2
3
4
5
6
7
import { Spin } from 'antd';
...

return (
<Spin size='large' spinning={true}>
...
</Spin>

if the spinning is false then the Spin is displayed, otherwise will showing.

every call will showing, we can perform it in axios intercepter.

Go to http.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

// Add a request interceptor
axios.interceptors.request.use(function (config) {
// Do something before request is sent
// Show Spin
return config;
}, function (error) {
// Do something with request error
// Hide Spin
return Promise.reject(error);
});

// Add a response interceptor
axios.interceptors.response.use(function (response) {
// Any status code that lie within the range of 2xx cause this function to trigger
// Do something with response data
// Hide Spin
return response;
}, function (error) {
// Any status codes that falls outside the range of 2xx cause this function to trigger
// Do something with response error
// Hide Spin
return Promise.reject(error);
});
1
touch src/redux/reducers/LoadingReducer.js

copy CollapsedReducer.js and modified as below

1
2
3
4
5
6
7
8
9
10
11
12
export const LoadingReducer = (prevState = { isLoading: false }, action) => {
// console.log(action);
const { type, payload } = action;
switch (type) {
case 'change_loading':
let newState = { ...prevState };
newState.isLoading = payload;
return newState;
default:
return prevState;
}
}

also we need login this reducer to store.js.

1
2
3
4
5
6
7
8
9
10
11
12
import { createStore, combineReducers } from 'redux';
import { CollapsedReducer } from './reducers/CollapsedReducer';
import { LoadingReducer } from './reducers/LoadingReducer';

const reducer = combineReducers({
CollapsedReducer,
LoadingReducer
})

const store = createStore(reducer);

export default store;

add connect to NewsRouter.js

1
2
3
4
5
6
7
8
9
10
11
import { connect } from 'react-redux'
...
function NewsRouter(props) {
...
<Spin size='large' spinning={props.isLoading}>
...
}

const mapStateToProps = ({ LoadingReducer: { isLoading } }) => ({ isLoading });

export default connect(mapStateToProps)(NewsRouter);

Back to http.js to dispatch isLoading to store.

1
2
3
4
5
6
import store from '../redux/store';
...
store.dispatch({
type:'change_loading',
payload: true,
});

4. Persist redux state

We chose redux persist to implement this feature.

1
yarn add redux-persist

we have to modify our store.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { createStore, combineReducers } from 'redux';
import { CollapsedReducer } from './reducers/CollapsedReducer';
import { LoadingReducer } from './reducers/LoadingReducer';

import { persistStore, persistReducer } from 'redux-persist'
import storage from 'redux-persist/lib/storage' // defaults to localStorage for web

const reducer = combineReducers({
CollapsedReducer,
LoadingReducer
})

const persistConfig = {
key: 'collapsed',
storage,
blacklist: ['LoadingReducer']
}

const persistedReducer = persistReducer(persistConfig, reducer)

const store = createStore(persistedReducer)
const persistor = persistStore(store)

export {
store,
persistor,
};

Go to App.js

1
2
3
4
5
6
7
8
9
10
11
12
13
import { store, persistor } from './redux/store'
import { PersistGate } from 'redux-persist/integration/react'
...

export default function App() {
return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
...
</PersistGate>
</Provider>
)
}