Aujourd'hui je vous propose de pratiquer ReactJS en reproduisant le système de Todolist. Pour cet exercice nous allons aussi utiliser du Typescript afin d'avoir un code plus organisé et d'éviter les petites erreurs de typage.
Configuration du projet
Avant de pouvoir commencer à travailler il va falloir configurer notre projet pour supporter l'utilisation des modules, du typescript et du jsx. Nous allons du coup mettre en place une configuration webpack centrée sur l'utilisation du typescript (nous n'utiliserons pas Babel ici). Nous allons aussi utiliser TSLint pour s'assurer de la qualité du code
npm i -D ts-loader tslint tslint-config-standard tslint-loader typescript webpack webpack-dev-server cross-env @types/react
npm i react classnames
Ensuite la configuration est plutôt classique
const path = require('path')
const webpack = require('webpack')
let config = {
entry: './src/main.tsx',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'main.js',
publicPath: '/dist/'
},
resolve: {
extensions: ['.js', '.ts', '.tsx']
},
devServer: {
noInfo: true
},
module: {
rules: [
{
test: /\.tsx?/,
loader: 'tslint-loader',
enforce: 'pre',
exclude: [/node_modules/]
},
{
test: /\.tsx?/,
loader: 'ts-loader',
exclude: [/node_modules/]
}
]
},
plugins: [
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: JSON.stringify(process.env.NODE_ENV)
}
})
]
}
module.exports = config
Enfin on configure TSLint et Typescript (n'hésitez pas à adapter la configuration de TSLint à vos standards).
// tsconfig.json
{
"files": [
"src/main.tsx"
],
"compilerOptions": {
"target": "es5",
"module": "es2015",
"moduleResolution": "node",
"sourceMap": true,
"strict": true,
"allowJs": true,
"jsx": "react"
},
"exclude": ["node_modules"]
}
// tslint.json
{
"extends": "tslint-config-standard",
"rules": {
"quotemark": [
true,
"single",
"avoid-escape",
"jsx-double"
]
}
}
Enfin j'ajoute les scripts dans mon package.json pour un accès plus rapide aux commandes de développement et de build.
"scripts": {
"dev": "cross-env NODE_ENV=development webpack-dev-server --hot",
"build": "cross-env NODE_ENV=production webpack"
},
Création du Store
Nous allons séparer la partie données dans une classe dédiée. Ceci afin de pouvoir ajouter plus de fonctionnalité plus tard (synchronisation sur un serveur, utilisation du localstorage...). Même si ReactJS ne vous impose pas de structure particulière, on essaiera d'éviter au maximum les mutations au sein de notre classe car cela rend les comparaisons plus difficiles plus tard.
import { Todo } from './Interfaces'
declare type ChangeCallback = (store: TodoStore) => void
export default class TodoStore {
private static i = 0
public todos: Todo[] = []
private callbacks: ChangeCallback[] = []
/**
* Crée un système d'auto increment
**/
private static increment () {
return this.i++
}
/**
* Informe les écouteurs d'un changement au sein du Store
* */
inform () {
this.callbacks.forEach(cb => cb(this))
}
/**
* Permet d'ajouter un écouteur
* */
onChange (cb: ChangeCallback) {
this.callbacks.push(cb)
}
addTodo (title: string): void {
this.todos = [{
id: TodoStore.increment(),
title: title,
completed: false
}, ...this.todos]
this.inform()
}
removeTodo (todo: Todo): void {
this.todos = this.todos.filter(t => t !== todo)
this.inform()
}
toggleTodo (todo: Todo): void {
this.todos = this.todos.map(t => t === todo ? { ...t, completed: !t.completed } : t)
this.inform()
}
updateTitle (todo: Todo, title: string): void {
this.todos = this.todos.map(t => t === todo ? { ...t, title } : t)
this.inform()
}
toggleAll (completed = true) {
this.todos = this.todos.map(t => completed !== t.completed ? { ...t, completed } : t)
this.inform()
}
clearCompleted (): void {
this.todos = this.todos.filter(t => !t.completed)
this.inform()
}
}
Création du composant
Maintenant que le code du composant est posé, il ne nous reste plus qu'à gérer la partie interface à travers notre composant React.
import * as React from 'react'
import { render } from 'react-dom'
import TodoList from './TodoList'
render(
<TodoList/>,
document.getElementById('app') as Element
)
import * as React from 'react'
import TodoStore from './TodoStore'
import TodoItem from './TodoItem'
import * as cx from 'classnames'
type FilterOptions = 'all' | 'completed' | 'active'
const Filters = {
completed: (todo: Todo) => todo.completed,
active: (todo: Todo) => !todo.completed,
all: (todo: Todo) => true
}
interface Todo {
id: number
title: string
completed: boolean
}
interface TodoListProps { }
interface TodoListState {
todos: Todo[],
newTodo: string,
filter: FilterOptions
}
export default class TodoList extends React.PureComponent<TodoListProps, TodoListState> {
private store: TodoStore = new TodoStore()
private toggleTodo: (todo: Todo) => void
private destroyTodo: (todo: Todo) => void
private updateTitle: (todo: Todo, title: string) => void
private clearCompleted: () => void
constructor (props: TodoListProps) {
super(props)
this.state = {
todos: [],
newTodo: '',
filter: 'all'
}
// On souscrit aux changements du store
this.store.onChange((store) => {
this.setState({ todos: store.todos })
})
// On injecte les méthodes du store en méthode du composant
this.toggleTodo = this.store.toggleTodo.bind(this.store)
this.destroyTodo = this.store.removeTodo.bind(this.store)
this.updateTitle = this.store.updateTitle.bind(this.store)
this.clearCompleted = this.store.clearCompleted.bind(this.store)
}
get remainingCount (): number {
return this.state.todos.reduce((count, todo) => !todo.completed ? count + 1 : count, 0)
}
get completedCount (): number {
return this.state.todos.reduce((count, todo) => todo.completed ? count + 1 : count, 0)
}
componentDidMount () {
this.store.addTodo('Salut')
this.store.addTodo('les gens')
}
render () {
let { todos, newTodo, filter } = this.state
let todosFiltered = todos.filter(Filters[filter])
let remainingCount = this.remainingCount
let completedCount = this.completedCount
return <section className="todoapp">
<header className="header">
<h1>todos</h1>
<input
className="new-todo"
value={newTodo}
placeholder="What needs to be done?"
onKeyPress={this.addTodo}
onInput={this.updateNewTodo}/>
</header>
<section className="main">
{todos.length > 0 &&
<input className="toggle-all" type="checkbox" checked={remainingCount === 0} onChange={this.toggle}/>}
<label htmlFor="toggle-all">Mark all as complete</label>
<ul className="todo-list">
{todosFiltered.map(todo => {
return <TodoItem
todo={todo}
key={todo.id}
onToggle={this.toggleTodo}
onDestroy={this.destroyTodo}
onUpdate={this.updateTitle}
/>
})}
</ul>
</section>
<footer className="footer">
{remainingCount > 0 && <span
className="todo-count"><strong>{remainingCount}</strong> item{remainingCount > 1 && 's'} left</span>}
<ul className="filters">
<li>
<a href="#/" className={cx({ selected: filter === 'all' })} onClick={this.setFilter('all')}>All</a>
</li>
<li>
<a href="#/active" className={cx({ selected: filter === 'active' })}
onClick={this.setFilter('active')}>Active</a>
</li>
<li>
<a href="#/completed" className={cx({ selected: filter === 'completed' })}
onClick={this.setFilter('completed')}>Completed</a>
</li>
</ul>
{completedCount > 0 &&
<button className="clear-completed" onClick={this.clearCompleted}>Clear completed</button>}
</footer>
</section>
}
updateNewTodo = (e: React.FormEvent<HTMLInputElement>) => {
this.setState({ newTodo: (e.target as HTMLInputElement).value })
}
addTodo = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
this.store.addTodo(this.state.newTodo)
this.setState({ newTodo: '' })
}
}
toggle = (e: React.FormEvent<HTMLInputElement>) => {
this.store.toggleAll(this.remainingCount > 0)
}
setFilter = (filter: FilterOptions) => {
return (e: React.MouseEvent<HTMLElement>) => {
this.setState({ filter })
}
}
}