Tutoriel Vidéo React React par la pratique : Todolist

Télécharger la vidéo Télécharger les sources

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

}