Skip to content

Commit 5eecc0f

Browse files
committed
refactor: upgrade to React 18 + TypeScript + Vite with modern state management
- Replace Create React App with Vite + @vitejs/plugin-react - Upgrade to React 18 with createRoot API - Add TypeScript 5 strict mode throughout - Add HooksDemo: useState, useEffect, useContext, useReducer, useMemo, useCallback, useRef - Add ConcurrentDemo: Suspense, startTransition, useDeferredValue - Add StateManagementDemo: Zustand, Jotai, TanStack Query - Add React Router v6 navigation between chapters - Add Vitest + React Testing Library unit tests - Add GitHub Actions CI workflow
1 parent 85df1bf commit 5eecc0f

File tree

14 files changed

+579
-0
lines changed

14 files changed

+579
-0
lines changed

.github/workflows/ci.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main, refactor]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
lint-test-build:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
- uses: actions/setup-node@v4
15+
with:
16+
node-version: '20'
17+
cache: 'npm'
18+
- run: npm ci
19+
- run: npm run lint
20+
- run: npm run typecheck
21+
- run: npm run test:coverage
22+
- run: npm run build

index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>React 18 Tutorial</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="/src/main.tsx"></script>
11+
</body>
12+
</html>

package.json

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"name": "react-tutorial",
3+
"version": "2.0.0",
4+
"private": true,
5+
"description": "🐅 React 18 Tutorial — Hooks, Concurrent Features, State Management",
6+
"scripts": {
7+
"dev": "vite --open",
8+
"build": "tsc && vite build",
9+
"preview": "vite preview",
10+
"test": "vitest run",
11+
"test:watch": "vitest",
12+
"test:coverage": "vitest run --coverage",
13+
"lint": "eslint src packages --ext .ts,.tsx",
14+
"lint:fix": "eslint src packages --ext .ts,.tsx --fix",
15+
"typecheck": "tsc --noEmit"
16+
},
17+
"dependencies": {
18+
"react": "^18.3.0",
19+
"react-dom": "^18.3.0",
20+
"react-router-dom": "^6.23.0",
21+
"zustand": "^4.5.0",
22+
"jotai": "^2.8.0",
23+
"@tanstack/react-query": "^5.40.0"
24+
},
25+
"devDependencies": {
26+
"@testing-library/jest-dom": "^6.4.0",
27+
"@testing-library/react": "^16.0.0",
28+
"@testing-library/user-event": "^14.5.0",
29+
"@types/react": "^18.3.0",
30+
"@types/react-dom": "^18.3.0",
31+
"@typescript-eslint/eslint-plugin": "^7.0.0",
32+
"@typescript-eslint/parser": "^7.0.0",
33+
"@vitejs/plugin-react": "^4.3.0",
34+
"@vitest/coverage-v8": "^1.4.0",
35+
"eslint": "^9.0.0",
36+
"eslint-plugin-react-hooks": "^4.6.0",
37+
"jsdom": "^24.0.0",
38+
"typescript": "^5.4.0",
39+
"vite": "^5.3.0",
40+
"vitest": "^1.4.0"
41+
},
42+
"engines": {
43+
"node": ">=18.0.0"
44+
}
45+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { Suspense, useState, startTransition, useDeferredValue, type FC } from 'react'
2+
3+
// ─── Simulated slow component ─────────────────────────────────────────────────
4+
function SlowList({ query }: { query: string }) {
5+
const items = Array.from({ length: 500 }, (_, i) => `Item ${i + 1}`).filter(
6+
item => item.toLowerCase().includes(query.toLowerCase())
7+
)
8+
return (
9+
<ul style={{ maxHeight: 200, overflow: 'auto', border: '1px solid #ccc', padding: '0.5rem' }}>
10+
{items.map(item => <li key={item}>{item}</li>)}
11+
</ul>
12+
)
13+
}
14+
15+
// ─── startTransition Demo ─────────────────────────────────────────────────────
16+
function TransitionDemo() {
17+
const [inputValue, setInputValue] = useState('')
18+
const [query, setQuery] = useState('')
19+
const [isPending, setIsPending] = useState(false)
20+
21+
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
22+
setInputValue(e.target.value)
23+
setIsPending(true)
24+
startTransition(() => {
25+
setQuery(e.target.value)
26+
setIsPending(false)
27+
})
28+
}
29+
30+
return (
31+
<section>
32+
<h3>startTransition</h3>
33+
<p>Type to filter 500 items. The input stays responsive while the list update is deferred.</p>
34+
<input
35+
value={inputValue}
36+
onChange={handleChange}
37+
placeholder="Filter items..."
38+
style={{ padding: '0.5rem', marginBottom: '0.5rem', display: 'block' }}
39+
/>
40+
{isPending && <p style={{ color: 'gray' }}>Updating list...</p>}
41+
<SlowList query={query} />
42+
</section>
43+
)
44+
}
45+
46+
// ─── useDeferredValue Demo ────────────────────────────────────────────────────
47+
function DeferredDemo() {
48+
const [text, setText] = useState('')
49+
const deferredText = useDeferredValue(text)
50+
const isStale = text !== deferredText
51+
52+
return (
53+
<section>
54+
<h3>useDeferredValue</h3>
55+
<p>The list below uses a deferred version of the input — it lags behind intentionally to keep the input snappy.</p>
56+
<input
57+
value={text}
58+
onChange={e => setText(e.target.value)}
59+
placeholder="Type to search..."
60+
style={{ padding: '0.5rem', marginBottom: '0.5rem', display: 'block' }}
61+
/>
62+
<div style={{ opacity: isStale ? 0.5 : 1, transition: 'opacity 0.2s' }}>
63+
<SlowList query={deferredText} />
64+
</div>
65+
</section>
66+
)
67+
}
68+
69+
// ─── Suspense Demo ────────────────────────────────────────────────────────────
70+
function AsyncData() {
71+
// Simulate a resource that suspends
72+
const data = ['React 18', 'Concurrent Mode', 'Suspense', 'Server Components']
73+
return (
74+
<ul>
75+
{data.map(item => <li key={item}>{item}</li>)}
76+
</ul>
77+
)
78+
}
79+
80+
// ─── Main Component ───────────────────────────────────────────────────────────
81+
export const ConcurrentDemo: FC = () => {
82+
return (
83+
<div>
84+
<h2>⚡ React 18 Concurrent Features</h2>
85+
86+
<Suspense fallback={<p>Loading async data...</p>}>
87+
<section>
88+
<h3>Suspense</h3>
89+
<p>Suspense lets you declaratively specify the loading state for a part of the component tree.</p>
90+
<AsyncData />
91+
</section>
92+
</Suspense>
93+
94+
<TransitionDemo />
95+
<DeferredDemo />
96+
</div>
97+
)
98+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import {
2+
useState,
3+
useEffect,
4+
useContext,
5+
useReducer,
6+
useMemo,
7+
useCallback,
8+
useRef,
9+
createContext,
10+
type FC,
11+
type ReactNode,
12+
} from 'react'
13+
14+
// ─── useContext Demo ──────────────────────────────────────────────────────────
15+
interface ThemeContextValue {
16+
theme: 'light' | 'dark'
17+
toggleTheme: () => void
18+
}
19+
20+
const ThemeContext = createContext<ThemeContextValue>({
21+
theme: 'light',
22+
toggleTheme: () => {},
23+
})
24+
25+
function ThemeProvider({ children }: { children: ReactNode }) {
26+
const [theme, setTheme] = useState<'light' | 'dark'>('light')
27+
const toggleTheme = useCallback(() => {
28+
setTheme(prev => (prev === 'light' ? 'dark' : 'light'))
29+
}, [])
30+
return (
31+
<ThemeContext.Provider value={{ theme, toggleTheme }}>
32+
{children}
33+
</ThemeContext.Provider>
34+
)
35+
}
36+
37+
// ─── useReducer Demo ──────────────────────────────────────────────────────────
38+
interface CounterState {
39+
count: number
40+
step: number
41+
}
42+
43+
type CounterAction =
44+
| { type: 'increment' }
45+
| { type: 'decrement' }
46+
| { type: 'reset' }
47+
| { type: 'setStep'; payload: number }
48+
49+
function counterReducer(state: CounterState, action: CounterAction): CounterState {
50+
switch (action.type) {
51+
case 'increment': return { ...state, count: state.count + state.step }
52+
case 'decrement': return { ...state, count: state.count - state.step }
53+
case 'reset': return { ...state, count: 0 }
54+
case 'setStep': return { ...state, step: action.payload }
55+
default: return state
56+
}
57+
}
58+
59+
// ─── Main Demo Component ──────────────────────────────────────────────────────
60+
export const HooksDemo: FC = () => {
61+
// useState
62+
const [name, setName] = useState('')
63+
64+
// useEffect
65+
useEffect(() => {
66+
document.title = name ? `Hello, ${name}!` : 'React Hooks Demo'
67+
return () => { document.title = 'React Tutorial' }
68+
}, [name])
69+
70+
// useReducer
71+
const [state, dispatch] = useReducer(counterReducer, { count: 0, step: 1 })
72+
73+
// useRef
74+
const inputRef = useRef<HTMLInputElement>(null)
75+
76+
// useMemo
77+
const expensiveValue = useMemo(() => {
78+
return Array.from({ length: 1000 }, (_, i) => i).reduce((a, b) => a + b, 0)
79+
}, [])
80+
81+
// useCallback
82+
const handleReset = useCallback(() => {
83+
dispatch({ type: 'reset' })
84+
setName('')
85+
inputRef.current?.focus()
86+
}, [])
87+
88+
return (
89+
<ThemeProvider>
90+
<div>
91+
<h2>📌 React Hooks</h2>
92+
93+
<section>
94+
<h3>useState + useEffect</h3>
95+
<input
96+
ref={inputRef}
97+
value={name}
98+
onChange={e => setName(e.target.value)}
99+
placeholder="Type your name..."
100+
style={{ padding: '0.5rem', marginRight: '0.5rem' }}
101+
/>
102+
<p>Document title updates via useEffect: <strong>{name || '(empty)'}</strong></p>
103+
</section>
104+
105+
<section>
106+
<h3>useReducer</h3>
107+
<p>Count: <strong>{state.count}</strong> (step: {state.step})</p>
108+
<button onClick={() => dispatch({ type: 'increment' })}>+{state.step}</button>
109+
<button onClick={() => dispatch({ type: 'decrement' })} style={{ margin: '0 0.5rem' }}>-{state.step}</button>
110+
<button onClick={handleReset}>Reset</button>
111+
<div style={{ marginTop: '0.5rem' }}>
112+
<label>Step: </label>
113+
<input
114+
type="number"
115+
value={state.step}
116+
onChange={e => dispatch({ type: 'setStep', payload: Number(e.target.value) })}
117+
style={{ width: 60, padding: '0.25rem' }}
118+
/>
119+
</div>
120+
</section>
121+
122+
<section>
123+
<h3>useMemo</h3>
124+
<p>Sum of 0..999 (memoized): <strong>{expensiveValue}</strong></p>
125+
</section>
126+
127+
<ThemeToggle />
128+
</div>
129+
</ThemeProvider>
130+
)
131+
}
132+
133+
function ThemeToggle() {
134+
const { theme, toggleTheme } = useContext(ThemeContext)
135+
return (
136+
<section>
137+
<h3>useContext</h3>
138+
<p>Current theme: <strong>{theme}</strong></p>
139+
<button onClick={toggleTheme}>Toggle Theme</button>
140+
</section>
141+
)
142+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { render, screen, fireEvent } from '@testing-library/react'
3+
import { HooksDemo } from '../../packages/hooks-demo/src/HooksDemo'
4+
5+
describe('HooksDemo', () => {
6+
it('renders the hooks demo heading', () => {
7+
render(<HooksDemo />)
8+
expect(screen.getByText('📌 React Hooks')).toBeInTheDocument()
9+
})
10+
11+
it('updates name via useState', () => {
12+
render(<HooksDemo />)
13+
const input = screen.getByPlaceholderText('Type your name...')
14+
fireEvent.change(input, { target: { value: 'Alice' } })
15+
expect(screen.getByText('Alice')).toBeInTheDocument()
16+
})
17+
18+
it('increments counter via useReducer', () => {
19+
render(<HooksDemo />)
20+
const incrementBtn = screen.getByText('+1')
21+
fireEvent.click(incrementBtn)
22+
expect(screen.getByText('1')).toBeInTheDocument()
23+
})
24+
25+
it('resets counter to 0', () => {
26+
render(<HooksDemo />)
27+
const incrementBtn = screen.getByText('+1')
28+
fireEvent.click(incrementBtn)
29+
fireEvent.click(incrementBtn)
30+
const resetBtn = screen.getByText('Reset')
31+
fireEvent.click(resetBtn)
32+
expect(screen.getByText('0')).toBeInTheDocument()
33+
})
34+
35+
it('displays memoized sum', () => {
36+
render(<HooksDemo />)
37+
expect(screen.getByText('499500')).toBeInTheDocument()
38+
})
39+
})

0 commit comments

Comments
 (0)