npx create-react-app react-playground
GET https://api.chucknorris.io/jokes/random
// install axios
npm i axios
npm start
Parent
Child
do something
Parent
Child
do something
import { useRef } from 'react';
function Component() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current?.focus();
}
return <div>
<input ref={inputRef} />
<button onClick={handleClick}>Focus the input</button>
</div>
}
Parent
Child
ref
<button>
function Parent() {
const inputRef = useRef(null);
const focusInput = () => inputRef.current?.focus();
return <>
<MySuperInput ref={inputRef} />
<button onClick={focusInput}>
Focus
</button>
</>
}
const MySuperInput = forwardRef((props, ref) => {
return <div>
<input ref={ref} />
</div>
})
function Parent() {
const inputApiRef = useRef(null);
const focusInput = () => inputApiRef.current?.focus();
return <>
<MySuperInput ref={inputApiRef} />
<button onClick={focusInput}>
Focus
</button>
</>
}
const MySuperInput = forwardRef((props, ref) => {
const inputRef = useRef(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus()
}), [inputRef])
return <div>
<input ref={inputRef} />
</div>
})
import { useState } from "react";
import { AlbumItem } from "./AlbumItem";
export const AlbumVoting = () => {
const [albums, setAlbums] = useState(new Array(15).fill({
id: 0,
title: '',
rating: 0
}).map((album, index) => ({ ...album, id: index })));
function handleChange (updatedAlbum) {
setAlbums(albums => albums.map(album => album.id === updatedAlbum.id ? updatedAlbum : album));
};
return <div>
{albums.map(album => <AlbumItem key={album.id} album={album} onChange={handleChange} />)}
</div>
}
import { Album } from "./AlbumVoting";
export const AlbumItem = ({ album, onChange }) => {
function handleChange(change) {
onChange({
...album,
...change
});
}
return <p>
Title:
<input value={album.title}
onChange={(e) => handleChange({ title: e.target.value })}
/>
Rating
<input type='number'
value={album.rating}
onChange={(e) => handleChange({ rating: Number(e.target.value) })}
/>
</p>
}
AlbumVoting.jsx
AlbumItem.jsx
const ExpensiveComponentMemoized = React.memo(function ExpensiveComponent() {
...
});
<ExpensiveComponentMemoized />
const useFibonacci = (n) => {
const result = useMemo(() => fibonacci(n), [n]);
return result;
}
function fibonacci(n) {
return n < 1 ? 0
: n <= 2 ? 1
: fibonacci(n - 1) + fibonacci(n - 2)
}
function Component({me}) {
const handleClick = useCallback(
(name) => console.log(`Hello ${name} and ${me}`)
, [me]);
return <ExpensiveComponent onClick={handleClick} />;
}
export const AlbumItem = ({ album, onChange }) => {
function handleChange(change) {
onChange({
...album,
...change
});
}
return <p>
Title:
<input value={album.title}
onChange={(e) => handleChange({ title: e.target.value })}
/>
Rating
<input type='number'
value={album.rating}
onChange={(e) => handleChange({ rating: Number(e.target.value) })}
/>
</p>
}
import { useState } from "react";
import { AlbumItem } from "./AlbumItem";
export const AlbumVoting = () => {
const [albums, setAlbums] = useState(new Array(15).fill({
id: 0,
title: '',
rating: 0
}).map((album, index) => ({ ...album, id: index })));
function handleChange(updatedAlbum) {
setAlbums(albums => albums.map(album => album.id === updatedAlbum.id ? updatedAlbum : album));
};
return <div>
{albums.map(album => <AlbumItem key={album.id} album={album} onChange={handleChange} />)}
</div>
}
AlbumItem.jsx
AlbumVoting.jsx
const MyContext = React.createContext(false);
function App() {
return <MyContext.Provider value={true}>
<Component />
</MyContext.Provider>;
}
function Component() {
const value = useContext(MyContext);
return <div>{value}</div>;
}
const MyContext = React.createContext({});
function App() {
const [value, setValue) = useState(0);
return <MyContext.Provider value={{ value, setValue}}>
<Component />
</MyContext.Provider>;
}
function Component() {
const {value, setValue} = useContext(MyContext);
return <>
<div>{value}</div>
<button onClick={() => setValue(value + 1)}>Click</button>
</>;
}
// not exported - private
const MyContext = React.createContext({});
export function MyContextProvider({children}) {
const [value, setValue) = useState(0);
return <MyContext.Provider value={{ value, setValue}}>
{children}
</MyContext.Provider>;
}
// only way to read the value:
export const useMyContext = () => useContext(MyContext);
// --------- in another file ----------
import {useMyContext} from './my-context';
function Component() {
const { value, setValue } = useMyContext();
return <>
<div>{value}</div>
<button onClick={() => setValue(value + 1)}>Click</button>
</>;
}
<MyContextProvider>
<Component />
</MyContextProvider>
const MyContext = React.createContext({});
export function MyContextProvider({initialState, children}: Props) {
const [value, setValue] = useState(initialState);
return <MyContext.Provider value={{value, setValue}}>
{children}
</MyContext.Provider>;
}
<MyContextProvider initialState={5}>
...
</MyContextProvider>
export function UserInfoPane() {
const { user } = useUser();
return <div>
{user ? <CurrentUserInfo /> : <LoginForm />}
</div>
}
export function LoginForm() {
const { login } = useUser();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
return <div>
<input placeholder="username" value={username} onChange={(e) => setUsername(e.target.value)} />
<input placeholder="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<button onClick={() => login({username, email})}>Login</button>
</div>
}
type UserContext = {
user: User | null,
login: (user: User) => void;
logout: () => void;
}
const useUser = () => useContext(UserContext);
export function UserInfoPane() {
const { user } = useUser();
return <div>
{user ? <CurrentUserInfo /> : <LoginForm />}
</div>
}
export function LoginForm() {
const { login } = useUser();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
return <div>
<input placeholder="username" value={username} onChange={(e) => setUsername(e.target.value)} />
<input placeholder="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<button onClick={() => login({username, email})}>Login</button>
</div>
}
type UserContext = {
user: User | null,
login: (user: User) => void;
logout: () => void;
}
UserContextProvider
UserInfoPane
CurrentUserInfo
LoginForm
= missing
const useUser = () => useContext(UserContext);
import {add} from './add';
it('adds numbers', () => {
expect(add(1, 3)).toBe(4);
})
import {add} from './add';
test('adding two numbers', () => {
expect(add(1, 3)).toBe(4);
})
export const add = (a, b) => a + b;
import {add} from './add';
it('adds numbers', () => {
expect(add(1, 3)).toBe(4);
})
import {add} from './add';
it('adds numbers', () => {
// Arrange
const a = 1;
const b = 3;
// Act
const result = add(a, b);
// Assert
expect(result).toBe(4);
})
expect(add(1, 3)).toBe(4);
subject
matcher
import {add} from './add';
it('adds numbers', () => {
expect(add(1, 3)).toBe(4);
})
import {add} from './add';
it('adds numbers', () => {
expect(add(1, 3)).not.toBe(5);
})
expect('How are you?').toEqual(expect.stringContaining('How'));
expect({a: 1, b: 2}).toBe({a: 1, b: 2}); // ❌ DON'T
expect({a: 1, b: 2}).toEqual({a: 1, b: 2}); // ✅ DO
expect({a: 1, b: 2}).toEqual(expect.objectContaining({a: 1}));
expect({a: 1, b: 2}).toHaveProperty('a', 1);
expect({
one: 1,
two: {
nested: 2
}
}).toHaveProperty('two.nested', 2);
expect([1,2,3]).toBe([1,2,3]); // ❌
expect([1,2,3]).toEqual([1,2,3]); // ✅
expect([1,2,3]).toEqual(expect.arrayContaining([1,2]));
expect(testedFn()).toThrow(); // ❌ DON'T
expect(() => testedFn()).toThrow(); // ✅ DO
function doNTimes(fn, n) {
for(let i = 0 ; i < n; i++) {
fn();
}
}
// ❌ DON'T - better to use mocks (later)
it('calls the function n times', () => {
expect.assertions(3);
function fn() {
expect(true).toBe(true);
}
doNTimes(fn, 3)
})
{
"name": "...",
"jest": {
"transformIgnorePatterns": [
"/node_modules/(?!(axios))"
]
},
...
}
test.skip('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
npm i -D @babel/plugin-proposal-private-property-in-object
npm test
export function divideWithRemainder(a, b) {
if (b === 0) {
throw new Error('Cannot divide by zero.');
}
const result = Math.ceil(a / b);
const remainder = a % b;
return {
result,
remainder
};
}
const fn = jest.fn(); // empty function
// pass implementation
const fn = jest.fn(() => {
return 5;
});
// override implementation
fn.mockImplementation(() => {
return 6;
});
fn.mockReturnValue(5);
fn.mockResolvedValue(5); // returns promise which resolves in 5
fn.mockReturnValueOnce(1);
fn.mockReturnValueOnce(2);
fn.mockReturnValue(3);
fn() // 1
fn() // 2
fn() // 3
fn() // 3
const fn = jest.fn(() => 42); // empty function
expect(fn).toHaveBeenCalled(); // fails
fn();
expect(fn).toHaveBeenCalled(); // passes
expect(fn).toHaveBeenCalledTimes(1);
fn(1,2,3)
expect(fn).toHaveBeenLastCalledWith(1,2,3);
expect(fn).toHaveReturnedWith(42); // test output
function doNTimes(fn, n) {
for(let i = 0 ; i < n; i++) {
fn();
}
}
jest.spyOn(Math, 'random');
Math.random();
expect(Math.random).toHaveBeenCalled();
// change behavior
const randomMock = jest.spyOn(Math, 'random');
randomMock.mockReturnValue(1);
expect(Math.random()).toBe(1);
import { fetchUser } from './fetch-user';
import axios from 'axios';
jest.mock('axios');
it('fetches user data', async () => {
const response = { data: { id: 1, name: 'Martin' } };
axios.get.mockResolvedValue(response);
expect(await fetchUser()).toEqual({ id: 1, name: 'Martin' });
});
import axios from 'axios';
function fetchUser() {
return axios.get('/url').then((response) => response.data);
}
Type of Query | 0 Matches | 1 Match | >1 Matches | Retry (Async/Await) |
---|---|---|---|---|
Single Element | ||||
getBy... | Throw error | Return element | Throw error | No |
queryBy... | Return null | Return element | Throw error | No |
findBy... | Throw error | Return element | Throw error | Yes |
Multiple Elements | ||||
getAllBy... | Throw error | Return array | Return array | No |
queryAllBy... | Return [ ] | Return array | Return array | No |
findAllBy... | Throw error | Return array | Return array | Yes |
import { screen } from '@testing-library/react';
screen.getByRole('button', {name: /submit/i});
getByRole('button', { name: 'Submit' });
// // checked input type checkbox
getByRole('checkbox', { checked: true });
// usually h2
getByRole('heading', { level: 2 });
screen.getByText('Hello World'); // full string match
screen.getByText('llo Worl', {exact: false}); // substring match
screen.getByText('hello world', {exact: false}); // ignore case
screen.getByText(/World/); // regexp
// custom function
screen.getByText((content, element) => content.startsWith('Hello'));
<div class="background red" data-testid="background">
...
</div>
const backgroundEl = screen.getByTestId('background');
import { screen, within } from '@testing-library/react';
const container = screen.getByTestId('container');
const helloMessage = within(container).getByText('hello');
waitForElementToBeRemoved
)import { screen, waitFor } from '@testing-library/react';
await waitFor(() => {
expect(screen.getByText('Some text')).toBeInTheDocument();
});
// click on an element
fireEvent.click(screen.getByText('Login'));
// change input value
fireEvent.change(getByLabelText(/username/i), {target: {value: 'martin'}});
// check text content
const element = screen.getByTestId('title');
expect(element).toHaveTextContent('This is title');
// check if is in the document
const element = screen.queryByText('Submit');
expect(element).toBeInTheDocument();
// test focus
const input = screen.getByTestId('password');
expect(input).toHaveFocus();
// test checkbox state
const rememberPass = screen.getByRole('checkbox', {name: 'Remember password'});
expect(rememberPass).toBeChecked();
import { render, screen } from '@testing-library/react';
it('renders', () => {
render(<App />);
const element = screen.queryByText('Hello');
expect(element).toBeInTheDocument();
});
1. render -> check LoginForm is visible
import { act, renderHook } from "@testing-library/react";
import { useCounter } from "./use-counter";
it('increments', () => {
const {result} = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
act(() => result.current.increment());
expect(result.current.count).toBe(1);
})
export function useCounter() {
const [count, setCount] = useState(0);
const increment =() => setCount(count + 1);
return { count, increment };
}
export function useCounter(initialValue) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(count + 1);
const randomize = () => setCount(Math.random());
return { count, increment, randomize };
}
Children
<WrapperComponent>
</WrapperComponent>
function Component() {
return <MousePosition render={
(x, y) => <p>Position is x={x}, y={y}</p>
} />
}
function MousePosition({ render }) {
...
return <>{render(position.x, position.y)}</>;
}
function Component() {
return <div>
<Counter>
{({counter, increment}) => <>
<div>Counter value: {counter}</div>
<button onClick={increment}>INC</button>
</>}
</Counter>
</div>
}
function Counter({children}) {
const [counter, setCounter] = useState(0);
function increment() {
setCounter(counter + 1);
}
return <>{children({counter, increment})}</>
}
<JokeFetcher>
{({joke, isLoading}) => <p>{isLoading ? 'Loading...' : joke}</p>}
</JokeFetcher>
<JokeErrorBoundary>
<Joke />
</JokeErrorBoundary>
import React from "react";
export class JokeErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
// optional
componentDidCatch(error, errorInfo) {
console.error(error, errorInfo);
}
render() {
const {error, hasError} = this.state;
if (hasError) {
// fallback
return <div>{String(error)}</div>;
}
return this.props.children;
}
}
export function ThrowComponent() {
throw 'Something went wrong.'
return <></>
}
Routable pages
Navbar
import { Outlet } from "react-router";
export function Layout() {
return <div>
<NavBar />
<Outlet />
</div>
}
Here other routes render
Routable pages
<Outlet />
<Navbar />
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
const router = createBrowserRouter([
... // (later)
]);
function App() {
return (
<RouterProvider router={router} />
);
}
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{
index: true,
element: <Home />
},
{
path: 'about',
element: <About />
},
{
path: 'articles',
element: <ArticlesContainer />,
children: [
{
path: ':articleId',
element: <Article />
}
{
path: ':articleId/comments',
element: <ArticleComments />
}
]
}
]
}
]);
Must have <Outlet /> inside
Layout component
Routable pages
Navbar
<Outlet>
Routable pages
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/articles">Articles</Link>
<Link to="/articles/{articleId}">Specific article</Link>
const navigate = useNavigate();
navigate('/articles');
import { useParams } from "react-router-dom";
function Joke() {
const params = useParams();
return (
{params.category}
);
}
/path-segment/:pathParam/something?query1=one&query2=two
useSearchParams
useParams
GET https://api.chucknorris.io/jokes/random?category={category}
stale-while-revalidate
import useSWR from 'swr'
const fetcher = (...args) =>
fetch(...args).then(res => res.json());
function Joke() {
const { data, isValidating } = useSWR(
`https://api.chucknorris.io/jokes/random`,
fetcher
);
return (
<p>{isValidating ? 'Loading...' : data.value}</p>
);
}
import useSWR, {useSWRConfig} from 'swr'
const fetcher = (...args) =>
fetch(...args).then(res => res.json());
function Joke() {
const { data, isValidating } = useSWR(
`https://api.chucknorris.io/jokes/random`,
fetcher
);
const { mutate } = useSWRConfig();
return <div>
<button onClick={() => mutate(`https://api.chucknorris.io/jokes/random`)}>
Load next
</button>
<p>{isValidating ? 'Loading...' : data.value}</p>
</div>;
}
function Parent() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ComponentWithLoading />
</Suspense>
)
}
const { data, isValidating } = useSWR(url, fetcher, { suspense: true });
const { data, isValidating } = useSWR(url, fetcher);
// default export
const ComponentLazy = React.lazy(
() => import('./ComponentDefaultExport')
);
// rename named export as default
const ComponentLazy = React.lazy(() =>
import('./ComponentNamedExport')
.then(({ComponentNamedExport}) => ({default: ComponentNamedExport}))
);
function Parent() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ComponentLazy prop="value" />
</Suspense>
);
}
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [ {
path: 'about',
// key must be "Component"
lazy: () =>
import("./about").then(({ About }) => ({Component: About}))
},
]
}
]);
Must be under Component key
import {createPortal} from 'react-dom';
function Component() {
return <div>
{createPortal(<div>Hello</div>, document.body)}
</div>
}
<Modal>
<h1>Hello</h1>
<p>I am modal</p>
</Modal>
function reducer(state, action) {
switch (action.type) {
case "increment":
return state + 1;
case "randomize":
return Math.random();
default:
return state;
}
}
export function Counter() {
const [ state, dispatch ] = useReducer(reducer, 0);
return <div>
Counter: {state}
<button onClick={() => dispatch({ type: 'increment', payload: 1 })}>Increment</button>
<button onClick={() => dispatch({ type: 'randomize' })}>Randomize</button>
</div>
}
Initial state
type State = {
user: User | null;
timeLeft: number
}
type Action = LOGIN | LOGOUT | SESSION_TICK;
{
jokeSlice: {
currentJoke: "Chuck Norris can speak Braille.",
isLoading: false
},
counterSlice: {
count: 5
},
uiSlice: {
dropdownVisible: false
}
}
View
Reducer
Store
dispatch an action
update the store
view reads data from the store
increment = (state) => {
state.count += 1;
}
// /src/store/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
count: 0,
};
export const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state, action) => {
state.count += 1;
}
},
});
export const { increment } = counterSlice.actions;
export const counterReducer = counterSlice.reducer;
// src/store/store.js
import { configureStore } from '@reduxjs/toolkit';
import { jokeReducer } from './jokeSlice';
import { counterReducer } from './counterSlice';
import { uiReducer } from './uiSlice';
export const store = configureStore({
reducer: {
jokeSlice: jokeReducer,
counterSlice: counterReducer,
uiSlice: uiReducer
},
});
// index.jsx
import { store } from './store/store'
import { Provider } from 'react-redux'
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
import { useSelector } from 'redux-toolkit';
function Counter() {
const count = useSelector(state => state.counterSlice.count);
return <div>Current count: {count}</div>
}
import { useDispatch } from 'redux-toolkit';
import { increment } from "../store/counterSlice";
function IncrementButton() {
const dispatch = useDispatch();
return (
<button onClick={() => dispatch(increment())}>
Increment
</button>
);
}
// src/store/jokeSlice.js
import { createAsyncThunk } from '@reduxjs/toolkit';
export const fetchJoke = createAsyncThunk('Fetch joke',
async (category, { getState }) => {
const { loading } = getState().jokeSlice;
if (loading !== 'pending') {
return
}
const response = await fetch(`https://api.chucknorris.io/jokes/random?category=${category}`)
const data = await response.json();
return data.value
}
)
// src/store/jokeSlice.js
export const jokeSlice = createSlice({
// ...
extraReducers: (builder =>
builder
.addCase(fetchJoke.pending, (state) => {
if (state.loading === 'idle') {
state.loading = 'pending'
}
})
.addCase(fetchJoke.fulfilled, (state, action) => {
if (
state.loading === 'pending'
) {
state.loading = 'idle'
state.joke = action.payload
}
})
)
});
extraReducers = for dealing with actions defined somewhere else
const dispatch = useDispatch();
dispatch(fetchJoke(category));
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './src/index.tsx',
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js', '.jsx'],
},
output: {
filename: 'main.bundle.js',
path: path.resolve(__dirname, 'dist'),
},
plugins: [
new HtmlWebpackPlugin({template: './public/index.html'}),
],
};
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
module.exports = {
entry: './src/index.tsx',
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
},
plugins: [
new webpack.optimize.SplitChunksPlugin({minSize: 1}),
new HtmlWebpackPlugin({template: './public/index.html'}),
]
};