Intenzívny rýchlokurz o základoch práce v Reacte
Milan Herda / máj 2022
import { useState } from 'react';
const CounterButton = () => {
const [state, setState] = useState(initialState);
const clickHandler = () => {
setState((prevState) => prevState + 1);
};
return (
<button onClick={clickHandler}>{state}</button>
);
};
# PRESENTING CODE
Kontext vs. stav mimo komponent?
Architektonický pattern pre správu jednosmerného toku dát*
* Neskôr aj knižnica od Facebooku implementujúca tento pattern.
Dnes už deprecated.
Asi najpopulárnejšia knižnica implementujúca Flux
Knižnica pre zjednodušenie písania logiky pre Redux
Pochádza od autorov Reduxu a je odporúčaná ako nový a správny spôsob práce s Reduxom
Ako sme v Reduxe robili po starom
Prečo potrebujeme prebrať starý spôsob, keď existuje nový?
yarn add redux react-redux
# alebo lepšie:
yarn add @reduxjs/toolkit react-redux
# PRESENTING CODE
import { createStore } from 'redux';
import { createStore } from '@reduxjs/toolkit';
const initialState = {
cvCount: 0,
};
const rootReducer = (state = initialState, action) => {
if (action?.type === 'INCREMENT') {
return {
...state,
cvCount: state.cvCount + 1,
};
}
return state;
};
const store = createStore(rootReducer, initialState);
export default store;
# PRESENTING CODE
import { Provider } from 'react-redux';
import store from '@local/store';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<Provider store={store}>
<AuthContextProvider>
<BrowserRouter>
<App {...initialData} />
</BrowserRouter>
</AuthContextProvider>
</Provider>
</React.StrictMode>
);
# PRESENTING CODE
import { useSelector } from 'react-redux';
const Navigation = () => {
// ...
const cvCount = useSelector((state) => state.cvCount);
return (
<nav>
<Link to="/">Homepage</Link>
<Link to="/listing">Zoznam CV ({cvCount})</Link>{' '}
{/* ... */}
</nav>
);
};
export default Navigation;
# PRESENTING CODE
import { useDispatch, useSelector } from 'react-redux';
const Homepage = () => {
const cvCount = useSelector((state) => state.cvCount);
const dispatch = useDispatch();
const handleIncrement = (e) => {
e.preventDefault();
dispatch({ type: 'INCREMENT' });
};
return (
<>
<div>Aktuálny počet CV: {cvCount}</div>
<button onClick={handleIncrement}>Pridaj CV</button>
</>
);
};
export default Homepage;
# PRESENTING CODE
Objekt, v ktorom je uložený stav a ktorý poskytuje metódy na manipuláciu so stavom (dispatch, getState)
// store/index.js
import { createStore } from 'redux';
const store = createStore(rootReducer, initialState);
export default store;
// src/main.jsx
import { Provider } from 'react-redux';
import store from '@local/store';
<Provider store={store}>
<App />
</Provider>
Objekt predstavujúci stav aplikácie, ktorý chceme držať v Reduxe.
const initialState = {
appUser: { /* ... */ },
cvList: { /* ... */},
// ...
};
const store = createStore(rootReducer, initialState);
Objekt predstavujúci príkaz na zmenu stavu / udalosť, ktorá nastala a na jej základe treba zmeniť stav.
const dispatch = useDispatch();
dispatch({ type: 'INCREMENT' });
Pure funkcia, ktorá vypočíta nový stav. Vychádza pritom z predchádzajúceho stavu a akcie.
const rootReducer = (state = initialState, action) => {
if (action?.type === 'INCREMENT') {
return {
...state,
cvCount: state.cvCount + 1,
};
}
return state;
};
Prečo je state immutable a prečo je reducer pure function?
Predvídateľnosť, testovateľnosť a opakovateľnosť.
Funkcia, ktorá ako parameter dostane celý stav a vráti jeho časť alebo nejakú z neho vypočítanú hodnotu.
import { useSelector } from 'react-redux';
const cvCount = useSelector((state) => state.cvCount);
export const getCvCount = (state) => state.cvCount;
# PRESENTING CODE
import { getCvCount } from '@local/state/selectors';
const Homepage = () => {
const cvCount = useSelector(getCvCount);
// ...
};
# PRESENTING CODE
import { getCvCount } from '@local/state/selectors';
const Navigation = () => {
const cvCount = useSelector(getCvCount);
// ...
};
# PRESENTING CODE
export const incrementCvCount = () => {
return {
type: 'INCREMENT',
};
};
# PRESENTING CODE
import { incrementCvCount } from '@local/store/actions';
const handleIncrement = (e) => {
e.preventDefault();
dispatch(incrementCvCount());
};
# PRESENTING CODE
Funkcia, ktorá vytvorí a vráti objekt reprezentujúci akciu.
Môže mať parametre, tie potom najčastejšie posúva do payload-u
export const incrementCvCount = () => {
return {
type: 'INCREMENT',
};
};
dispatch(incrementCvCount(5));
# PRESENTING CODE
export const incrementCvCount = (amount = 1) => {
return {
type: 'INCREMENT',
payload: {
amount,
},
};
};
# PRESENTING CODE
const rootReducer = (state = initialState, action) => {
if (action?.type === 'INCREMENT') {
return {
...state,
cvCount: state.cvCount + action.payload.amount,
};
}
return state;
};
# PRESENTING CODE
Redux Toolkit
yarn add @reduxjs/toolkit react-redux
# PRESENTING CODE
import { configureStore } from '@reduxjs/toolkit';
import cvListReducer from '@local/store/slice/cvList';
const store = configureStore({
reducer: {
cvList: cvListReducer,
},
});
export default store;
# PRESENTING CODE
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
cvCount: 0,
};
export const cvListSlice = createSlice({
name: 'cvList', // použije sa v action.type
initialState,
reducers: {
// meno funkcie sa pouzije v action.type
increment: (state, action) => {
state.cvCount += action.payload ?? 1;
},
},
});
// RTK za nás sama vytvorí action creator funkcie.
// Hocijaký parameter pre action creator sa do reducera
// dostane ako hodnota action.payload
export const { increment } = cvListSlice.actions;
export default cvListSlice.reducer;
# PRESENTING CODE
import { increment } from '@local/store/slice/cvList';
const Homepage = () => {
const dispatch = useDispatch();
const handleIncrement = (e) => {
e.preventDefault();
dispatch(increment(5));
};
// ...
};
# PRESENTING CODE
Oproti createStore priamo predpokladá, že stav je zložený z viacerých modulov.
Modulu sa v RTK hovorí slice
RTK za nás rieši kombináciu reducerov, ale aj napríklad doplnenie enhanceru pre dev prostredie a uzitočných middlewarov
Knižnica, ktorú RTK používa na sledovanie zmien nad stavom, ktorý odovzdáva reducerom.
Reducer v RTK tak môže stav mutovať a immer sa postará o to, že v Reduxe bude stav immutable a po zmene sa vytvorí nový objekt.
Prečo?
Ja náročné napísať logiku zmeny immutable stavu bez chyby.
Pozor: immer je súčasťou iba createSlice reducerov. Mimo nich musíte robiť immutable updaty sami.
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
isLoggedIn: false,
username: null,
};
export const appUserSlice = createSlice({
name: 'appUser',
initialState,
reducers: {
login: (state, action) => {
state.isLoggedIn = true;
state.username = action.payload;
},
logout: (state) => {
state.isLoggedIn = false;
state.username = initialState.username;
},
},
});
export const { login, logout } = appUserSlice.actions;
export default appUserSlice.reducer;
# PRESENTING CODE
import { configureStore } from '@reduxjs/toolkit';
import cvListReducer from '@local/store/slice/cvList';
import appUserReducer from '@local/store/slice/appUser';
const store = configureStore({
reducer: {
cvList: cvListReducer,
appUser: appUserReducer,
},
});
export default store;
# PRESENTING CODE
import { useDispatch } from 'react-redux';
import { login } from '@local/store/slice/appUser';
function App() {
const pathname = usePathname();
const dispatch = useDispatch();
useEffect(() => {
fetch('/data/appUser.json', { method: 'POST' })
.then((response) => response.json())
.then((data) => {
if (data.id) {
dispatch(login(data.username));
}
});
}, []);
// ...
}
# PRESENTING CODE
import { Link } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { getCvCount } from '@local/store/selectors';
import { login, logout } from '@local/store/slice/appUser';
const Navigation = () => {
const cvCount = useSelector(getCvCount);
const isLoggedIn = useSelector((state) => state.appUser.isLoggedIn);
const username = useSelector((state) => state.appUser.username);
const dispatch = useDispatch();
return (
<nav>
<Link to="/">Homepage</Link> <Link to="/listing">Zoznam CV ({cvCount})</Link>{' '}
<Link to="/detail/cv-1">Detail</Link>{' '}
{isLoggedIn ? (
<>
<span>{username}</span>{' '}
<button onClick={() => dispatch(logout())}>Odhlásiť</button>
</>
) : (
<button onClick={() => dispatch(login('fero'))}>Prihlásiť</button>
)}
</nav>
);
};
export default Navigation;
# PRESENTING CODE
import { createSlice } from '@reduxjs/toolkit';
// ...
export const { increment } = cvListSlice.actions;
export const selectCvCount = (state) => state.cvList.cvCount;
export default cvListSlice.reducer;
# PRESENTING CODE
import { selectCvCount } from '@local/store/slice/cvList';
const Navigation = () => {
const cvCount = useSelector(selectCvCount);
// ...
};
# PRESENTING CODE
import { increment, selectCvCount } from '@local/store/slice/cvList';
const Homepage = () => {
const cvCount = useSelector(selectCvCount);
// ...
};
# PRESENTING CODE
Thunk je funkcia, ktorú môžeme používať ako action creator, tj. môžeme ju dispatchovať.
Avšak thunk nevracia objekt predstavujúci akciu, ale namiesto toho vracia funkciu, v ktorej sa môžu dispatchovať ďalšie akcie
Thunk je mechanizmus pre prácu s asynchrónnymi operáciami*
*thunk je iba jeden zo spôsobov (najpopulárnejší a preto defaultne v RTK)
// komponent
dispatch(loadUserData(42));
// slice súbor
export loadUserData = (userId) => (dispatch, getState) => {
dispatch(startLoading());
loadData(`/get-data/${userId}`).then((userData) => {
dispatch(setUserData(userData));
}).finally(() => {
dispatch(stopLoading());
});
};
# PRESENTING CODE
// komponent
dispatch(loadUserData(42));
// slice súbor
export loadUserData = (userId) => {
return (dispatch, getState) => {
dispatch(startLoading());
loadData(`/get-data/${userId}`).then((userData) => {
dispatch(setUserData(userData));
}).finally(() => {
dispatch(stopLoading());
});
};
};
# PRESENTING CODE
// ...
export const loadUserData = () => (dispatch) => {
dispatch(appUserSlice.actions.startFetching());
fetch('/data/appUser.json', { method: 'POST' })
.then((response) => response.json())
.then((data) => {
if (data.id) {
dispatch(login(data.username));
}
})
.finally(() => {
dispatch(appUserSlice.actions.stopFetching());
});
};
# PRESENTING CODE
const initialState = {
isFetching: false,
isLoggedIn: false,
username: null,
};
export const appUserSlice = createSlice({
// ...
reducers: {
// ...
startFetching: (state) => {
state.isFetching = true;
},
stopFetching: (state) => {
state.isFetching = false;
},
},
});
# PRESENTING CODE
import { loadUserData } from '@local/store/slice/appUser';
function App() {
const pathname = usePathname();
const dispatch = useDispatch();
useEffect(() => {
dispatch(loadUserData());
}, []);
// ...
}
# PRESENTING CODE
Upravte slice cvList tak, aby:
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
isFetching: false,
items: [],
};
export const cvListSlice = createSlice({
name: 'cvList',
initialState,
reducers: {
startFetching: (state) => {
state.isFetching = true;
},
stopFetching: (state) => {
state.isFetching = false;
},
setData: (state, action) => {
state.items = action.payload;
},
},
});
export const { setData } = cvListSlice.actions;
# PRESENTING CODE
export const loadCvList = () => (dispatch) => {
dispatch(cvListSlice.actions.startFetching());
fetch('/data/data.json')
.then((response) => response.json())
.then((data) => {
if (data?.cvList) {
dispatch(setData(data.cvList));
}
})
.finally(() => {
dispatch(cvListSlice.actions.stopFetching());
});
};
export const selectCvCount = (state) => state.cvList.items.length;
export default cvListSlice.reducer;
# PRESENTING CODE
import { loadCvList } from '@local/store/slice/cvList';
function App() {
const dispatch = useDispatch();
useEffect(() => {
dispatch(loadUserData());
dispatch(loadCvList());
}, []);
// ...
}
# PRESENTING CODE
Naozaj je dobrý nápad mať zrovna tieto dve akcie na tomto mieste?
Možno je vhodnejšie umiestnenie v main.jsx
import { useSelector } from 'react-redux';
import { selectCvCount } from '@local/store/slice/cvList';
const Homepage = () => {
const cvCount = useSelector(selectCvCount);
return (
<>
<h1>Homepage</h1>
<div>Aktuálny počet CV: {cvCount}</div>
</>
);
};
export default Homepage;
# PRESENTING CODE
import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
const Listing = () => {
const cvs = useSelector((state) => state.cvList.items);
return (
<>
<h1>Listing</h1>
<ul>
{cvs?.map((cv) => (
<li key={cv.id}>
<Link to={`/detail/${cv.id}`}>{cv.title}</Link>
</li>
))}
</ul>
</>
);
};
export default Listing;
# PRESENTING CODE
import { useParams } from 'react-router-dom';
import { useSelector } from 'react-redux';
const Detail = () => {
const params = useParams();
const cv = useSelector((state) => state.cvList.items?.find((cv) => cv.id === params.id));
if (!cv) {
return <div>CV sa nenašlo</div>;
}
return (
<>
<h1>{cv.title}</h1>
<dl>
<dt>Meno</dt>
<dd>{cv.name}</dd>
<dt>Priezvisko</dt>
<dd>{cv.lastname}</dd>
<dt>Jazyk CV</dt>
<dd>{cv.language}</dd>
</dl>
</>
);
};
export default Detail;
# PRESENTING CODE
Všimli ste si, ako sa nám opakuje logika pre načítanie dát z backendu?
Zamyslite sa, ako by ste túto duplicitu ostránili
V reduceri nesmiem generovať ID, náhodné hodnoty, ani nič iné.
Čo mám teda robiť v situácii, keď potrebujem na viacerých miestach dispatchovať akciu, ktorá potrebuje napr. vygenerované ID?
A čo v situácii, keď si chcem toto generovanie centralizovať?
const postsSlice = createSlice({
name: 'cvList',
initialState,
reducers: {
createCv: {
reducer(state, action) {
state.items.push(action.payload);
},
prepare(title, name, surname, language) {
return {
payload: {
id: nanoid(),
title,
name,
surname,
language,
},
};
}
}
}
});
// ...
dispatch(createCv(title, name, surname, language));
Veľmi, veľmi, naozaj veľmi odporúčam si prečítať kompletný
oficiálny Redux Essentials tutoriál.
Zoznam CV je stav našej FE aplikácie alebo databázy na BE?
Ak je to stav databázy, tak je Redux správny nástroj pre ich pamätanie?
Nie je tak Redux použitý iba ako akási cache a zoznam stavových flagov (isFetching...) pre BE stav?
Redux je nástroj pre udržiavanie stavu našej FE aplikácie.
Nástroj z RTK na manažovanie a zjednodušenie načítavania a cachovania dát z backendu.
Dáta, flagy a cachovacie kľúče sú uložené v Redux-e v samostatnom module (api slice).
Zabudovaná podpora REST API, ale je možné použiť aj GraphQL
Samostatná knižnica na riešenie toho istého problému.
Komponentová knižnica založená na Material Design-e
yarn add @mui/material @emotion/react @emotion/styled
# PRESENTING CODE
yarn add @fontsource/roboto
# PRESENTING CODE
Nemôžem fonty len tak nalinkovať napr. priamo z fonts.googleapi.com?
Nie. Lebo GDPR.
// ...
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
// ...
# PRESENTING CODE
// ...
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import CssBaseline from '@mui/material/CssBaseline';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<CssBaseline />
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
</React.StrictMode>
);
# PRESENTING CODE
// ...
import Typography from '@mui/material/Typography';
const Homepage = () => {
// ...
return (
<>
<Typography variant="h1">Homepage</Typography>
{/* ... */}
</>
);
};
# PRESENTING CODE
// ...
import Typography from '@mui/material/Typography';
const Homepage = () => {
// ...
return (
<>
<Typography variant="h3" component="h1">
Homepage
</Typography>
{/* ... */}
</>
);
};
# PRESENTING CODE
Dokumentácia k styled
// ...
import Typography from '@mui/material/Typography';
import { styled } from '@mui/material/styles';
const Homepage = () => {
// ...
return (
<>
<Title variant="h3" component="h1">
Homepage
</Title>
{/* ... */}
</>
);
};
const Title = styled(Typography)(() => ({
backgroundColor: 'navy',
fontStyle: 'italic',
}));
# PRESENTING CODE
// ...
import { styled } from '@mui/material/styles';
// ...
const Title = styled(Typography)(({ theme }) => ({
backgroundColor: 'steelblue',
fontStyle: 'italic',
padding: theme.spacing(1),
marginTop: theme.spacing(4),
marginBottom: theme.spacing(4),
}));
# PRESENTING CODE
// ...
import { styled } from '@mui/material/styles';
// ...
const Title = styled(Typography)(({ theme }) => ({
backgroundColor: 'steelblue',
fontStyle: 'italic',
padding: theme.spacing(1),
marginTop: theme.spacing(4),
marginBottom: theme.spacing(4),
color: theme.palette.primary.main,
}));
# PRESENTING CODE
import { createTheme } from '@mui/material/styles';
const theme = createTheme({
palette: {
primary: {
main: '#ffa500',
},
},
});
export default theme;
# PRESENTING CODE
// ...
import theme from '@local/styles/theme';
import { ThemeProvider } from '@mui/material/styles';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<CssBaseline />
<Provider store={store}>
<ThemeProvider theme={theme}>
<BrowserRouter>
<App />
</BrowserRouter>
</ThemeProvider>
</Provider>
</React.StrictMode>
);
# PRESENTING CODE
MUI má vlastný komponent Link, ktorý je vhodný používať namiesto <a>.
Ale taký istý element má aj react-router-dom
Potrebujeme skombinovat oba, lebo:
Začneme tým, že si urobíme vlastný Link.
Takže už budú tri!
import { forwardRef } from 'react';
import { Link as RouterLink } from 'react-router-dom';
const Link = forwardRef(
(props, ref) => {
const { href, ...otherProps } = props;
return <RouterLink ref={ref} to={href} {...otherProps} />;
}
);
export default Link;
# PRESENTING CODE
import { forwardRef } from 'react';
import { Link as RouterLink, LinkProps as RouterLinkProps } from 'react-router-dom';
const Link = forwardRef<any, Omit<RouterLinkProps, 'to'> & { href: RouterLinkProps['to'] }>(
(props, ref) => {
const { href, ...otherProps } = props;
return <RouterLink ref={ref} to={href} {...otherProps} />;
}
);
export default Link;
# PRESENTING CODE
Potom upravíme tému, kde povieme, že MUI Link a MUI Button Link komponent sa má rendrovať cez náš Link
import { createTheme } from '@mui/material';
import Link from '@local/components/router/Link';
const theme = createTheme({
palette: {
// ...
},
components: {
MuiLink: {
defaultProps: {
component: Link,
},
},
MuiButtonBase: {
defaultProps: {
LinkComponent: Link,
},
},
},
});
export default theme;
# PRESENTING CODE
import { createTheme } from '@mui/material';
import { LinkProps } from '@mui/material/Link';
import Link from '@local/components/router/Link';
const theme = createTheme({
palette: {
// ...
},
components: {
MuiLink: {
defaultProps: {
component: Link,
} as LinkProps,
},
MuiButtonBase: {
defaultProps: {
LinkComponent: Link,
},
},
},
});
export default theme;
# PRESENTING CODE
* Ehm... Minimizing bundle size
// ...
import Link from '@mui/material/Link';
const Listing = () => {
const cvs = useSelector((state) => state.cvList.items);
return (
<>
<Typography variant="h3" component="h1">
Listing
</Typography>
<ul>
{cvs?.map((cv) => (
<li key={cv.id}>
<Link to={`/detail/${cv.id}`}>{cv.title}</Link>
</li>
))}
</ul>
</>
);
};
# PRESENTING CODE
// ...
import Container from '@mui/material/Container';
import { styled } from '@mui/system';
// ...
return (
<MyContextProvider>
<div>
<Navigation />
<ContentContainer>
<MyRoutes />
</ContentContainer>
</div>
</MyContextProvider>
);
const ContentContainer = styled(Container)(({ theme }) => ({
marginTop: theme.spacing(8),
}));
# PRESENTING CODE
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { styled } from '@mui/material/styles';
const Navigation = () => {
// ...
return (
<AppBar>
<Toolbar>
// ...
</Toolbar>
</AppBar>
);
};
const MenuLinks = styled(Box)(() => ({
flexGrow: 1,
}));
# PRESENTING CODE
const Navigation = () => {
// ...
return (
<AppBar>
<Toolbar>
<Button variant="secondary" to="/">
B2C
</Button>
<MenuLinks>
<Button variant="secondary" to="/listing">
Zoznam CV ({cvCount})
</Button>
</MenuLinks>
{isLoggedIn ? (
<>
<Typography component="span">{username}</Typography>
<Button variant="secondary" onClick={() => dispatch(logout())}>
Odhlásiť
</Button>
</>
) : (
<Button variant="secondary" onClick={() => dispatch(login('fero'))}>
Prihlásiť
</Button>
)}
</Toolbar>
</AppBar>
);
};
# PRESENTING CODE
Vyskúšali sme si len pár vecí, ale MUI toho ponúka oveľa viac.