Podsumowując wiedzę zdobytą w dwóch pierwszych częściach tutoriala React Podstawy, piszemy własną aplikację Todo App korzystając z biblioteki React. Nasza aplikacja będzie wykorzystywać wszystkie zdobyte dotychczas umiejętności wliczając w to obsługę localStorage przy użyciu własnego hooka useLocalStorage.
React Todo App – tworzymy projekt
Tworzymy naszą aplikację przy użyciu dobrze nam już chyba znanej komendy:
npx create-react-app todo-app2
Po zainstalowaniu wszystkiego, przechodzimy do folderu naszego projektu używając komendy cd (change directory):
cd todo-app2
W folderze src tworzymy folder components, gdzie w pliku useLocalStorage.js umieszczamy hook, który napisaliśmy sobie lekcję wcześniej:
import { useState, useEffect } from "react";
function getStorageValue(key, defaultValue) {
const saved = localStorage.getItem(key);
const initial = JSON.parse(saved);
return initial || defaultValue;
}
export const useLocalStorage = (key, defaultValue) => {
const [value, setValue] = useState(() => {
return getStorageValue(key, defaultValue);
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue];
};
W folderze components tworzymy komponent o nazwie TodoItem.js. Będzie to komponent wielokrotnego użytku korzystający z pewnych propsów. Jako że komponent wielokrotnego użytku – nie możemy użyć export default:
export const TodoItem = ({ task, deleteTask, toggleCompleted }) => {
return (
<div className="todo-item">
<input type="checkbox"/>
<p></p>
<button>X</button>
</div>
);
};
Oto nasz komponent. Korzysta z propsów task, deleteTask i toggleCompleted. Ten pierwszy to będzie obiekt zadania, drugi i trzeci to funkcje do usuwania bądź zmieniania statusu na ukończony / nieukończony.
W markupie mamy zaś div z klasą todo-item oraz trzy elementy – checkbox do ustawiania, aby element był ukończony, paragraf do wyświetlania treści oraz przycisk X do usuwania elementu z listy.
Dodajmy sobie klasę todo-item do App.css. Chcemy, aby elementy input, p oraz button wyświetlały się obok siebie:
.todo-item {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 8px;
}
Teraz stworzymy w folderze components plik TodoList.js zawierający komponent TodoList:
import { useState } from "react";
function TodoList() {
const defaultTasks = [{
id: 1,
text: 'Wyprowadzić psa',
completed: true
},
{
id: 2,
text: 'Kupić mleko',
completed: false
}];
const [tasks, setTasks] = useState( defaultTasks);
const [text, setText] = useState('');
return (
<div className="todo-list">
<ul>
{tasks.map(task => (
<li>{task.text}</li>
))}
</ul>
<input value={text}/>
<button>Add</button>
</div>
);
}
export default TodoList;
Mamy tutaj – na razie – komponent, defaultowe dwa zadania, stan dla naszych zadań oraz dla tekstu, który pochodzi z elementu <input>. Ponadto mamy guzik, który będzie nam potrzebny do dodawania nowych zadań jak również listę, gdzie renderowane są zadania w formie elementów <li>.
Na razie będzie to brzydko wyglądać, będzie pozbawione pewnych funkcjonalności – ale będzie działać.
Musimy tylko zaimportować i użyć komponent <TodoList/> w naszym pliku App.js:
import './App.css';
import React from 'react';
import TodoList from './components/TodoList';
function App() {
return (
<div className="App">
<TodoList />
</div>
);
}
export default App;
Todo lista – podstawowe funkcjonalności
Na początku napiszmy sobie funkcję dodającą nowe zadanie w komponencie TodoList:
function TodoList() {
(...)
const [tasks, setTasks] = useState( defaultTasks);
const [text, setText] = useState('');
function addTask(text) {
const newTask = {
id: Date.now(),
text,
completed: false
};
setTasks([...tasks, newTask]);
setText('');
}
(...)
}
export default TodoList;
Funkcja addTask tworzy zadanie. Jego id to data, jego tekst to przekazany do niej argument text, domyślnie zadanie nie jest ustawione na ukończone. Następnie do setTasks przekazujemy aktualne zadania plus nowe zadanie wewnątrz listy – tak używamy useState w sytuacji, gdy stan jest listą.
Ustawiamy jeszcze tekst na pusty, aby po kliknięciu przycisku dodającego zadanie wyczyścił się nasz input. Teraz pora na funkcję usuwającą zadania:
function deleteTask(id) {
setTasks(tasks.filter(task => task.id !== id));
}
Bardzo prosta funkcja – przyjmuje id i ustawia setTasks na wartość tasks z odfiltrowanym zadaniem o podanym przez nas id. Tak w hooku useState usuwamy wartości, gdy stanem jest lista.
Teraz pora na funkcję, która przyjmuje ID zadania i zwraca wszystkie zadania, ale jedno z nich (odpowiadające podanemu ID) będzie miało zmieniony status 'completed’ na jego logiczne przeciwieństwo:
function toggleCompleted(id) {
setTasks(tasks.map(task => {
if (task.id === id) {
return {...task, completed: !task.completed};
} else {
return task;
}
}));
Myślę, że musimy przywyknąć do tych wszystkich 'mapów’, 'filterów’ i 'spreadów’ – w ten sposób działamy ze stanem, który jest typu złożonego (lista). To nie proste push i pop znane z JavaScript.
Teraz musimy jeszcze poprawić wartość zwracaną przez komponent TodoList. Po pierwsze, w miejsce naszego tagu <ul> wrzucimy teraz listę elementów <TodoItem /> z przekazanymi odpowiednio kluczem, zadaniem, oraz funkcjami do usuwania i zmieniania statusu:
{tasks.map(task => (
<TodoItem
key={task.id}
task={task}
deleteTask={deleteTask}
toggleCompleted={toggleCompleted}
/>
))}
Po drugie – potrzebna nam obsługa elementu <input>, który ma teraz poprawnie ustawiać tekst:
<input
value={text}
onChange={e => setText(e.target.value)}
/>
Po trzecie – element <button> ma nam teraz tworzyć nowe zadanie:
<button onClick={() => addTask(text)}>Add</button>
Całość powinna wyglądać w ten sposób:
import { useState } from "react";
import { TodoItem } from "./TodoItem";
function TodoList() {
const defaultTasks = [{
id: 1,
text: 'Wyprowadzić psa',
completed: true
},
{
id: 2,
text: 'Kupić mleko',
completed: false
}];
const [tasks, setTasks] = useState( defaultTasks);
const [text, setText] = useState('');
function addTask(text) {
const newTask = {
id: Date.now(),
text,
completed: false
};
setTasks([...tasks, newTask]);
setText('');
}
function deleteTask(id) {
setTasks(tasks.filter(task => task.id !== id));
}
function toggleCompleted(id) {
setTasks(tasks.map(task => {
if (task.id === id) {
return {...task, completed: !task.completed};
} else {
return task;
}
}));
}
return (
<div className="todo-list">
{tasks.map(task => (
<TodoItem
key={task.id}
task={task}
deleteTask={deleteTask}
toggleCompleted={toggleCompleted}
/>
))}
<input
value={text}
onChange={e => setText(e.target.value)}
/>
<button onClick={() => addTask(text)}>Add</button>
</div>
);
}
export default TodoList;
To jeszcze nie wszystko – musimy odpowiednio zmienić plik TodoItem.js. Po pierwsze, niech korzysta z przekazanej mu funkcji toggleCompleted do zmieniania statusu zadania:
export const TodoItem = ({ task, deleteTask, toggleCompleted }) => {
function handleChange() {
toggleCompleted(task.id);
}
return (
<div className="todo-item">
<input
type="checkbox"
checked={task.completed}
onChange={handleChange}
/>
<p></p>
<button>X</button>
</div>
);
};
Funkcja handleChange wykorzystuje przekazaną komponentowi (jako props) funkcję toggleCompleted z odpowiednim id (pochodzącym z props, pod nazwą task.id).
Nasz checkbox, jeżeli zadanie jest ukończone (task.completed zwraca true) jest zaznaczony, zaś zmiana zaznaczenia (onChange) wywołuje funkcję handleChange.
Teraz wypada odpowiednio wyświetlić treść naszego zadania. Treść znajduje się w propsie task pod atrybutem text:
<p>{task.text}</p>
Wypadałoby również nadawać klasę CSS 'completed’ tym zadaniom, które są już ukończone. Możemy to zrobić w ten sposób:
<p className={task.completed ? 'completed' : null}>{task.text}</p>
Oczywiście trzeba taką klasę w pliku App.css stworzyć:
.todo-item p.completed {
color: #888;
text-decoration: line-through;
}
Teraz kończymy nasz TodoItem – guzik służący do usuwania zadań ma je usuwać. Wykorzystamy onClick oraz funkcję usuwającą przekazaną jako props:
<button onClick={() => deleteTask(task.id)}> X </button>
Całość komponentu powinna wyglądać teraz tak:
import React from "react";
export const TodoItem = ({ task, deleteTask, toggleCompleted }) => {
function handleChange() {
toggleCompleted(task.id);
}
return (
<div className="todo-item">
<input
type="checkbox"
checked={task.completed}
onChange={handleChange}
/>
<p className={task.completed ? 'completed' : null}>{task.text}</p>
<button onClick={() => deleteTask(task.id)}> X </button>
</div>
);
};
Teraz nasza aplikacja powinna działać poprawnie.
Dodajemy obsługę localStorage – useLocalStorage hook
Teraz pora zaimplementować mechanizm używania local storage w naszej aplikacji przy użyciu hooka, który już napisaliśmy w lekcji poprzedniej. Mamy go przekopiowanego w pliku useLocalStorage.js i w takiej, nie zmienionej postaci, go sobie użyjemy.
Pierwsza rzecz, jakiej potrzebujemy, to odpowiednie importy w pliku TodoList.js:
import { useState, useEffect } from "react";
import { TodoItem } from "./TodoItem";
import { useLocalStorage } from './useLocalStorage';
Możemy zapytać – ale jak to? Po co nam useEffect? Przecież hook useLocalStorage już z tego korzysta, i taki był cel tworzenia naszego hooka, aby nie powtarzać zbędnego kodu.
Cóż, używa. Ale useLocalStorage jest dostosowany do obsługi prostych danych klucz-wartość. Natomiast nasze tasks to lista. Mamy już napisaną całą aplikację oraz cały hook, teraz potrzebny nam swego rodzaju adapter, mówiąc językiem wzorców projektowych, aby to wszystko skleić, najlepiej nie modyfikując już istniejących funkcjonalności.
A zatem tak – po dodaniu potrzebnych importów ustawiamy sobie nasze hooki:
import { useState, useEffect } from "react";
import { TodoItem } from "./TodoItem";
import { useLocalStorage } from './useLocalStorage';
function TodoList() {
const defaultTasks = [{
id: 1,
text: 'Wyprowadzić psa',
completed: true
},
{
id: 2,
text: 'Kupić mleko',
completed: false
}];
const [tasksLS, settasksLS] = useLocalStorage('tasks2', defaultTasks);
const [tasks, setTasks] = useState( () => {
return tasksLS;
});
const [text, setText] = useState('');
Mamy nasze domyślne zadania. Mamy zadania zaciągnięte z localStorage pod kluczem 'tasks2′. Jako że nic tam nie zapisaliśmy, do tasksLS zapiszą się te domyślne.
Dalej mamy useState, który jako stan początkowy zwraca zadania z tasksLS (gdzie na razie znajdą się te domyślne, bo nic do pod tym kluczem w storage nie mamy).
Teraz tylko kolejny element naszego adaptera – hook useEffect, który przy każdej zmianie stanu tasks zapisze ten stan w localStorage:
useEffect(() => {
settasksLS(tasks);
}, [tasks, settasksLS]);
Całość wygląda tak:
import { useState, useEffect } from "react";
import { TodoItem } from "./TodoItem";
import { useLocalStorage } from './useLocalStorage';
function TodoList() {
const defaultTasks = [{
id: 1,
text: 'Wyprowadzić psa',
completed: true
},
{
id: 2,
text: 'Kupić mleko',
completed: false
}];
const [tasksLS, settasksLS] = useLocalStorage('tasks2', defaultTasks);
const [tasks, setTasks] = useState( () => {
return tasksLS;
});
const [text, setText] = useState('');
useEffect(() => {
settasksLS(tasks);
}, [tasks, settasksLS]);
function addTask(text) {
const newTask = {
id: Date.now(),
text,
completed: false
};
setTasks([...tasks, newTask]);
setText('');
}
function deleteTask(id) {
setTasks(tasks.filter(task => task.id !== id));
}
function toggleCompleted(id) {
setTasks(tasks.map(task => {
if (task.id === id) {
return {...task, completed: !task.completed};
} else {
return task;
}
}));
}
return (
<div className="todo-list">
{tasks.map(task => (
<TodoItem
key={task.id}
task={task}
deleteTask={deleteTask}
toggleCompleted={toggleCompleted}
/>
))}
<input
value={text}
onChange={e => setText(e.target.value)}
/>
<button onClick={() => addTask(text)}>Add</button>
</div>
);
}
export default TodoList;
Dostosowaliśmy nasz kod dodając:
- hook useLocalStorage, który sprawdza, czy coś jest w local storage pod kluczem 'tasks2′, jeżeli nie – zwraca domyślne zadania
- useState, który na początku działania aplikacji zwraca tasksLS gdzie albo będą poprzednio zapisane zadania, albo te domyślne
- hook useEffect, który nasłuchuje na zmianę stanu tasks i podmienia tasksLS na nową wartość tasks
Tym sposobem nasza aplikacja działa. Dodajemy zadania, usuwamy zadania, zmieniamy stan – wszystko się zapisuje, możemy odświeżyć – mamy wszystko zapisane.
Możemy co najwyżej pobawić się różnymi szczegółami, dopracować tę naszą aplikację. Moje sugestie:
- poprawić wygląd, odświeżyć CSS
- sprawić, aby nie można było dodawać pustych zadań
- przenieść useLocalStorage do folderu hooks i poprawić importy
- sprawić, aby klucz używany przez useLocalStorage w <TodoList/> pochodził od propsów i przystosować komponent <TodoList/> do używania go w wielu miejscach jednocześnie
- dodać modal pytający, czy aby na pewno chcemy usunąć dany element
- jeżeli nam to nie straszne – zaciągać dane z jakiejś bazy albo API zamiast z localStorage albo wręcz dołączyć aplikację Reactową do jakiegoś backendu napisanego w dowolnym frameworku używającym dowolnego języka programowania
Tym niemniej, powiedzmy sobie szczerze – jak na trzecią lekcję to zrobiliśmy w bibliotece React kawał porządnej roboty. Możemy tym się, po odpowiednim dopieszczeniu, pochwalić na GitHubie.
Może nie jako najlepsza nasza aplikacja React, ale jedna z pierwszych, pokazująca nie tyle jakiś JavaScriptowy stos technologiczny typu MERN (Mongo Express React Node) co naszą umiejętność obsługi Reacta i potencjalne poradzenie sobie z projektem, który od strony backendowej używa np. Laravela zaś do frontendu Reacta właśnie.
Zrobiliśmy dzisiaj kawał dobrej roboty – gratulacje!