Todolist en React Hooks + TypeScript: de la compilación a la prueba

A partir de la versión 16.9, hay una nueva funcionalidad disponible en la biblioteca React JS: hooks . Permiten utilizar el estado y otras funciones de React, liberándote de tener que escribir una clase. El uso de componentes funcionales junto con los ganchos le permite desarrollar una aplicación cliente completa.



Consideremos la posibilidad de crear una versión todolista de una aplicación React Hooks usando TypeScript .



Montaje



La estructura del proyecto es la siguiente:



├── src

| ├── componentes

| ├── index.html

| ├── index.tsx

├── package.json

├── tsconfig.json

├── webpack.config.json



Archivo Package.json:
{
  "name": "todo-react-typescript",
  "version": "1.0.0",
  "description": "",
  "main": "index.tsx",
  "scripts": {
    "start": "webpack-dev-server --port 3000 --mode development --open --hot",
    "build": "webpack --mode production"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "ts-loader": "^5.2.1",
    "html-webpack-plugin": "^3.2.0",
    "typescript": "^3.8.2",
    "webpack": "^4.41.6",
    "webpack-cli": "^3.3.11",
    "webpack-dev-server": "^3.10.3"
  },
  "dependencies": {
    "@types/react": "^16.9.23",
    "@types/react-dom": "^16.9.5",
    "react": "^16.12.0",
    "react-dom": "^16.12.0"
  }
}


TypeScript, typescript, ts-loader, tsx- js-, React — @types/react @types/react-dom. html-webpack-plugin, dev- index.html — , production- .



Archivo Tsconfig.json:
{
  "compilerOptions": {
    "sourceMap": true,
    "noImplicitAny": false,
    "module": "commonjs",
    "target": "es6",
    "lib": [
      "es2015",
      "es2017",
      "dom"
    ],
    "removeComments": true,
    "allowSyntheticDefaultImports": false,
    "jsx": "react",
    "allowJs": true,
    "baseUrl": "./",
    "paths": {
      "components/*": [
        "src/components/*"
      ]
    }
  }
}


«jsx» . 3 : «preserve», «react» «react-native».







Archivo Webpack.config.json:
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: './src/index.tsx',
    resolve: {
        extensions: ['.ts', '.tsx', '.js']
    },
    output: {
        path: path.join(__dirname, '/dist'),
        filename: 'bundle.min.js'
    },
    module: {
        rules: [
            {
                test: /\.ts(x?)$/,
                exclude: /node_modules/,
                use: [
                    {
                        loader: "ts-loader"
                    }
                ]
            }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: './src/index.html'
        })
    ]
};


— ./src/index.tsx. resolve.extensions ts/tsx/js . ts-loader html-webpack-plugin. .



Desarrollo



En el archivo index.html, escribimos el contenedor donde se renderizará la aplicación:



<div id="root"></div>


En el directorio de componentes, cree nuestro primer componente vacío, App.tsx.

Archivo index.tsx:



import * as React from 'react';
import * as ReactDOM from 'react-dom';

import App from "./components/App";

ReactDOM.render (
    <App/>,
    document.getElementById("root")
);


La aplicación Todolist tendrá la siguiente funcionalidad:



  • agregar tarea
  • eliminar tarea
  • cambiar el estado de la tarea (completado / no completado)


Se verá así: un campo de texto para la entrada + el botón Agregar tarea, y debajo hay una lista de tareas agregadas. Puede eliminar tareas y cambiar su estado.







Para estos fines, puede dividir la aplicación en solo dos componentes: crear una nueva tarea y una lista de todas las tareas. Por lo tanto, App.tsx en la etapa inicial se verá así:



import * as React from 'react';
import NewTask from "./NewTask";
import TasksList from "./TasksList";

const App = () => {

    return (
        <>
            <NewTask />
            <TasksList />
        </>
    )
}

export default App;


En el directorio actual, cree y exporte componentes vacíos NewTask y TasksList. Dado que necesitamos asegurar la relación entre ellos, necesitamos determinar cómo sucederá esto. Hay dos enfoques para la comunicación entre componentes en React:



  1. Almacenar el estado actual de la aplicación y todos sus métodos en el componente principal (en nuestro caso, en App.tsx) y pasarlo a los componentes secundarios a través de accesorios (la forma clásica);
  2. Mantener separados los métodos de gestión estatal y estatal. En este caso, la aplicación debe estar empaquetada con un componente especial: el proveedor y los métodos y propiedades necesarios para los componentes secundarios deben pasarse a él (usando el gancho useContext).


Usaremos el segundo método y en este ejemplo descartaremos completamente los accesorios.



TypeScript al pasar accesorios
* , TypeScript :



const NewTask: React.FC<MyProps> = ({taskName}) => {...


React.FC, , ( ) :



interface MyProps {
    taskName: String;
}




useContext



Entonces, para transferir el estado, usaremos el gancho useContext. Le permite obtener y modificar datos en cualquiera de los componentes envueltos por el proveedor.



Ejemplo de UseContext
import * as React from 'react';
import {useContext} from "react";

interface Person {
    name: String,
    surname: String
}

export const PersonContext = React.createContext<Partial<Person>>({});

const PersonWrapper = () => {

    const person: Person = {
        name: 'Spider',
        surname: 'Man'
    }

    return (
        <>
            <PersonContext.Provider value={ person }>
                <PersonComponent />
            </PersonContext.Provider>
        </>
    )
}

const PersonComponent = () => {
    const person = useContext(PersonContext);
    return (
        <div>
            Hello, {person.name} {person.surname}!
        </div>
    )
}

export default PersonWrapper;


— name surname, String.



createContext . , TypeScript « » , Partial — .



— person, . , . useContext.



useReducer



También necesitará useReducer para un trabajo más conveniente con la tienda estatal.



Más sobre useReducer
useReducer , : , type, — payload. :



import * as React from 'react';
import {useReducer} from "react";

interface PersonState {
    name: String,
    surname: String
}

interface PersonAction {
    type: 'CHANGE',
    payload: PersonState
}


const personReducer = (state: PersonState, action: PersonAction): PersonState => {
    switch (action.type) {
        case 'CHANGE':
            return action.payload;
        default: throw new Error('Unexpected action');
    }
}


const PersonComponent = () => {

    const initialState = {
        name: 'Unknown',
        surname: 'Guest'
    }

    const [person, changePerson] = useReducer<React.Reducer<PersonState, PersonAction>>(personReducer, initialState);

    return (
        <div onClick={() => changePerson({type: 'CHANGE', payload: {name: 'Jackie', surname: 'Chan'}})}>
            Hello, {person.name} {person.surname}!
        </div>
    )
}

export default PersonComponent;


useReducer - personReducer, changePerson.



person initialState, changePerson .



CHANGE, , :



case 'CHANGE':
   return action.payload;
case 'CLEAR':
   return {
      name: 'Undefined',
      surname: 'Undefined'
   };




useContext + useReducer



Un reemplazo interesante para la biblioteca Redux puede ser el uso de contexto junto con useReducer. En este caso, el resultado del gancho useReducer (el estado devuelto y la función para actualizarlo) se pasará al contexto. Agreguemos estos ganchos a la aplicación:



import * as React from 'react';

import {useReducer} from "react";
import {Action, State, ContextState} from "../types/stateType";
import NewTask from "./NewTask";
import TasksList from "./TasksList";

//   
export const initialState: State = {
    newTask: '',
    tasks: []
}

// <Partial>      
export const ContextApp = React.createContext<Partial<ContextState>>({});

//  ,        Action   type  payload,     - State
export const todoReducer = (state: State, action: Action):State => {
    switch (action.type) {
        case ActionType.ADD: {
            return {...state, tasks: [...state.tasks, {
                    name: action.payload,
                    isDone: false
                }]}
        }
        case ActionType.CHANGE: {
            return {...state, newTask: action.payload}
        }
        case ActionType.REMOVE: {
            return {...state, tasks:  [...state.tasks.filter(task => task !== action.payload)]}
        }
        case ActionType.TOGGLE: {
            return {...state, tasks: [...state.tasks.map((task) => (task !== action.payload ? task : {...task, isDone: !task.isDone}))]}
        }
        default: throw new Error('Unexpected action');
    }
};

const App:  React.FC = () => {
//   todoReducer,     useReduser.     initialState,     (changeState)  .
    const [state, changeState] = useReducer<React.Reducer<State, Action>>(todoReducer, initialState);

    const ContextState: ContextState = {
        state,
        changeState
    };

//      useReducer -     
    return (
        <>
            <ContextApp.Provider value={ContextState}>
                <NewTask />
                <TasksList />
            </ContextApp.Provider>
        </>
    )
}

export default App;


Como resultado, logramos hacer un estado independiente del componente raíz, que se puede recibir y cambiar en componentes dentro del proveedor.



Mecanografiado. Agregar tipos a la aplicación



En el archivo stateType, escribimos los tipos de TypeScript para la aplicación:



import {Dispatch} from "react";

//       
export type Task = {
    name: string;
    isDone: boolean
}

export type Tasks = Task[];

//        ,      
export type State = {
    newTask: string;
    tasks: Tasks
}

//      
export enum ActionType {
    ADD = 'ADD',
    CHANGE = 'CHANGE',
    REMOVE = 'REMOVE',
    TOGGLE = 'TOGGLE'
}

//   ADD  CHANGE     
type ActionStringPayload = {
    type: ActionType.ADD | ActionType.CHANGE,
    payload: string
}

//   TOGGLE  REMOVE      Task
type ActionObjectPayload = {
    type: ActionType.TOGGLE | ActionType.REMOVE,
    payload: Task
}

//        
export type Action = ActionStringPayload | ActionObjectPayload;

//       -,     Action.  Dispatch    react
export type ContextState = {
    state: State;
    changeState: Dispatch<Action>
}


Usando contexto



Ahora el estado está listo y se puede usar en componentes. Comencemos con NewTask.tsx:



import * as React from 'react';

import {useContext} from "react";
import {ContextApp} from "./App";

import {TaskName} from "../types/taskType";
import {ActionType} from "../types/stateType";

const NewTask: React.FC = () => {
//  state  dispatch-
    const {state, changeState} = useContext(ContextApp);

//     todoReducer -     .      state .         React-
    const addTask = (event: React.FormEvent<HTMLFormElement>, task: TaskName) => {
        event.preventDefault();
        changeState({type: ActionType.ADD, payload: task})
        changeState({type: ActionType.CHANGE, payload: ''})
    }

//  -     
    const changeTask = (event: React.ChangeEvent<HTMLInputElement>) => {
        changeState({type: ActionType.CHANGE, payload: event.target.value})
    }

    return (
        <>
            <form onSubmit={(event)=>addTask(event, state.newTask)}>
                <input type='text' onChange={(event)=>changeTask(event)} value={state.newTask}/>
                <button type="submit">Add a task</button>
            </form>
        </>
    )
};

export default NewTask;


TasksList.tsx:



import * as React from 'react';

import {Task} from "../types/taskType";
import {ActionType} from "../types/stateType";
import {useContext} from "react";
import {ContextApp} from "./App";

const TasksList: React.FC = () => {
//     ( changeState)
    const {state, changeState} = useContext(ContextApp);

    const removeTask = (taskForRemoving: Task) => {
        changeState({type: ActionType.REMOVE, payload: taskForRemoving})
    }
    const toggleReadiness = (taskForChange: Task) => {
        changeState({type: ActionType.TOGGLE, payload: taskForChange})
    }

    return (
        <>
            <ul>
                {state.tasks.map((task,i)=>(
                    <li key={i} className={task.isDone ? 'ready' : null}>
                        <label>
                            <input type="checkbox" onChange={()=>toggleReadiness(task)} checked={task.isDone}/>
                        </label>
                        <div className="task-name">
                            {task.name}
                        </div>
                        <button className='remove-button' onClick={()=>removeTask(task)}>
                            X
                        </button>
                    </li>
                ))}
            </ul>
        </>
    )
};

export default TasksList;


¡La aplicación está lista! Queda por probarlo.



Pruebas



Para las pruebas, se utilizará Jest + Enzyme y @ testing-library / react .

Necesitas instalar las dependencias de desarrollo:



"@testing-library/react": "^10.4.3",
"@testing-library/react-hooks": "^3.3.0",
"@types/enzyme": "^3.10.5",
"@types/jest": "^24.9.1",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2",
"enzyme-to-json": "^3.3.4",
"jest": "^26.1.0",
"ts-jest": "^26.1.1",


Agregue configuraciones para jest a package.json:



 "jest": {
    "preset": "ts-jest",
    "setupFiles": [
      "./src/__tests__/setup.ts"
    ],
    "snapshotSerializers": [
      "enzyme-to-json/serializer"
    ],
    "testRegex": "/__tests__/.*\\.test.(ts|tsx)$",
    "moduleFileExtensions": [
      "ts",
      "tsx",
      "js",
      "jsx",
      "json",
      "node"
    ]
  },


y en el bloque "scripts", agregue un script para ejecutar pruebas:



"test": "jest"


Cree un nuevo directorio __tests__ en el directorio src y en él un archivo setup.ts con el siguiente contenido:



import {configure} from 'enzyme';
import * as ReactSixteenAdapter from 'enzyme-adapter-react-16';
const adapter = ReactSixteenAdapter as any;

configure({ adapter: new adapter() });


Creemos un archivo todoReducer.test.ts, en el que probaremos el reductor:



import {todoReducer} from "../reducers/todoReducer";
import {ActionType, Action, State} from "../types/stateType";
import {Task} from "../types/taskType";

describe('todoReducer',()=>{
    it('returns new state for "ADD" type', () => {
//      
        const initialState: State = {newTask: '', tasks: []};
//   'ADD'      'new task'
        const updateAction: Action = {type: ActionType.ADD, payload: 'new task'};
//       
        const updatedState = todoReducer(initialState, updateAction);
//      
        expect(updatedState).toEqual({newTask: '', tasks: [{name: 'new task', isDone: false}]});
    });

    it('returns new state for "REMOVE" type', () => {
        const task: Task = {name: 'new task', isDone: false}
        const initialState: State = {newTask: '', tasks: [task]};
        const updateAction: Action = {type: ActionType.REMOVE, payload: task};
        const updatedState = todoReducer(initialState, updateAction);
        expect(updatedState).toEqual({newTask: '', tasks: []});
    });

    it('returns new state for "TOGGLE" type', () => {
        const task: Task = {name: 'new task', isDone: false}
        const initialState: State = {newTask: '', tasks: [task]};
        const updateAction: Action = {type: ActionType.TOGGLE, payload: task};
        const updatedState = todoReducer(initialState, updateAction);
        expect(updatedState).toEqual({newTask: '', tasks: [{name: 'new task', isDone: true}]});
    });

    it('returns new state for "CHANGE" type', () => {
        const initialState: State = {newTask: '', tasks: []};
        const updateAction: Action = {type: ActionType.CHANGE, payload: 'new task'};
        const updatedState = todoReducer(initialState, updateAction);
        expect(updatedState).toEqual({newTask: 'new task', tasks: []});
    });
})


Para probar el reductor, basta con pasarle el estado actual y la acción, y luego capturar el resultado de su ejecución.



Probar el componente App.tsx, a diferencia del reductor, requiere el uso de métodos adicionales de diferentes bibliotecas. Archivo de prueba App.test.tsx:



import * as React from 'react';
import {shallow} from 'enzyme';
import {fireEvent, render, cleanup} from "@testing-library/react";
import App from "../components/App";

describe('<App />', () => {
// jest- afterEach    cleanup        
    afterEach(cleanup);

    it('hasn`t got changes', () => {
//   shallow  enzyme   -,    . 
        const component = shallow(<App />);
//        .           .   snapshots      -u: jest -u
        expect(component).toMatchSnapshot();
    });

//         (   DOM-),    async
    it('should render right input value',  async () => {
// render()     @testing-library/react"    shallow() ,    DOM-   .  container  —   div,     .
        const { container } = render(<App/>);
        expect(container.querySelector('input').getAttribute('value')).toEqual('');
//         'test'
        fireEvent.change(container.querySelector('input'), {
            target: {
                value: 'test'
            },
        })
//      'test'
        expect(container.querySelector('input').getAttribute('value')).toEqual('test');
//     .       
        fireEvent.click(container.querySelector('button'))
//        value
        expect(container.querySelector('input').getAttribute('value')).toEqual('');
    });
})


En el componente TasksList, compruebe si el estado pasado se muestra correctamente. Archivo TasksList.test.tsx:



import * as React from 'react';
import {ContextApp, initialState} from "../components/App";

import {shallow} from "enzyme";
import {cleanup, render} from "@testing-library/react";

import TasksList from "../components/TasksList";
import {State} from "../types/stateType";

describe('<TasksList />',() => {

    afterEach(cleanup);

//   
    const testState: State = {
        newTask: '',
        tasks: [{name: 'test', isDone: false}, {name: 'test2', isDone: false}]
    }

//   ContextApp   
    const Wrapper = () => {
        return (
            <ContextApp.Provider value={{state: testState}}>
                <TasksList/>
            </ContextApp.Provider>
            )
    }

    it('should render right tasks length', async () => {
        const {container} = render(<Wrapper/>);

//    
        expect(container.querySelectorAll('li')).toHaveLength(testState.tasks.length);
    });

})


Se puede realizar una comprobación similar del campo newTask para el componente NewTask comprobando el valor del elemento de entrada.



El proyecto se puede descargar del repositorio de GitHub .



Eso es todo, gracias por su atención.



Recursos



Reaccionar JS. Hooks que

trabajan con React Hooks y TypeScript



All Articles