React par la pratique : Todolist

Résumé Support

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.

  • 00:00 Configuration du projet
  • 17:26 Création du TodoStore
  • 28:47 Création du composant TodoList

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 }) } } }