DrillLab
第 03 / 08 节LESSON 03 / 08约 15 分钟~15 min

用一个 state 管四个页面Controlling four pages with one piece of state

没有 react-router。currentPage 是个字符串状态机,四个 && 决定谁显示。There is no react-router. currentPage is a string state machine, and four && checks decide which page shows.

2 个练习2 exercisesCab Booking · 第 2 部分Cab Booking · Part 2
这一页有什么On this page5
学完这节你会After this lesson you can
  • 用一个 currentPage state 管四个页面Control four pages with a single currentPage state
  • 说清 && 条件渲染和三元的区别,以及为什么这里用 &&Explain the difference between && and a ternary in conditional rendering, and why && fits here
  • 知道为什么 handleSelectCab 必须写在 App 里,而不是 CabCard 里Know why handleSelectCab has to live in App and not in CabCard
  • 看懂四个页面之间的转移图Read the transition diagram between the four pages
这在考试里考什么What the exam does with this

题目没给路由,所以你得自己决定「页面」怎么表示。写成四个 boolean(isHome / isLoading …)能跑,但两个同时为 true 时会同时渲染两个页面,测试 3 的 getByTestId 会因为找到多个而抛错。一个字符串 state 从根上排除了这种状态。The task gives you no router, so you have to decide how a page is represented. Four booleans (isHome, isLoading and so on) can work, but when two of them are true at the same time two pages render together, and the getByTestId in test 3 throws because it finds more than one match. A single string state rules that situation out from the start.

这节课要看的真实文件Real files this lesson looks at高亮行 = 需要你动手改的文件Highlighted rows are the files you edit
cab-booking-context/src/App.jsx状态机本体,四个页面的开关都在这里

提醒:源项目在磁盘上是做完的版本 —— 下面就是答案。想自己先写一遍的话,现在关上。Heads up: on disk this project is the finished version — what follows is the answer. Close this if you want to write it yourself first.

JSXApp.jsx源项目From source
1import { useState } from "react";
2import "./App.css";
3import { AppHeader } from "./components/AppHeader";
4import Home from "./components/Home/Home";
5import CabOptions from "./components/CabOptions/CabOptions";
6import Loading from "./components/Loading/Loading";
7import CabConfirmation from "./components/CabConfirmation/CabConfirmation";
8import { useCabContext } from "./context/CabContext";
9
10const title = "Cab Booking";
11
12const App = () => {
13 const [currentPage, setCurrentPage] = useState("home");
14 const { updateBookedCabDetails } = useCabContext();
15
16 const handleSelectCab = (cab) => {
17 updateBookedCabDetails(cab);
18 setCurrentPage("loading");
19 };
20
21 return (
22 <div className="App">
23 <AppHeader title={title} />
24
25 {currentPage === "home" && (
26 <Home onBookClick={() => setCurrentPage("cab-options")} />
27 )}
28
29 {currentPage === "cab-options" && (
30 <CabOptions onSelectCab={handleSelectCab} />
31 )}
32
33 {currentPage === "loading" && (
34 <Loading onComplete={() => setCurrentPage("cab-confirmation")} />
35 )}
36
37 {currentPage === "cab-confirmation" && (
38 <CabConfirmation onConfirm={() => setCurrentPage("home")} />
39 )}
40 </div>
41 );
42};
43
44export default App;
Source: cab-booking-context/src/App.jsx
cab-booking-context/src/components/Home/Home.jsx首页,把 onBookClick 往上抛The home page; it raises onBookClick upwards
JSXHome.jsx源项目From source
1import RideHistory from "./RideHistory";
2
3const Home = ({ onBookClick }) => {
4 return (
5 <main className="home-container">
6 <section className="hero-card">
7 <p className="eyebrow">HackerRide</p>
8 <h2>Book a Safe Ride with HackerRide</h2>
9 <p className="hero-copy">
10 Choose a cab, wait for confirmation, and review your latest rides.
11 </p>
12 <button
13 type="button"
14 className="primary-button"
15 data-testid="book-button"
16 onClick={onBookClick}
17 >
18 Book a Cab
19 </button>
20 </section>
21
22 <RideHistory />
23 </main>
24 );
25};
26
27export default Home;
Source: cab-booking-context/src/components/Home/Home.jsx
§01

四个页面 = 一个字符串 stateFour pages, one string state

转移图画出来,代码就是照抄Draw the transition diagram and the code just copies it

一句话:currentPage 只可能是四个字符串之一, 每个页面用 && 判断自己该不该出现。

先画转移图:

当前触发去哪
homebook-buttoncab-options
cab-options点某张卡的 Selectloading(同时写入 Context)
loading1 秒后自动cab-confirmation
cab-confirmationconfirm-buttonhome

四条转移,四个回调。代码里就是四个setCurrentPage(...), 分别通过 onBookClick / onSelectCab /onComplete / onConfirm 传下去。子组件一个都不知道「页面」这回事 —— 它们只知道自己有个回调要调。

为什么用 && 而不是三元:这里是「四选一」,用嵌套三元会变成a ? X : b ? Y : c ? Z : W, 读起来累而且加第五个页面要改结构。 四个平行的 && 一行一个页面,加页面就加一行

会追问:「为什么不用四个 boolean?」—— 因为 boolean 允许非法状态。isLoadingisHome 同时为 true 时页面上会有两个 <main>getByTestId 找到多个直接抛错。用一个字符串,非法状态在类型层面就不存在 —— 这个思路叫「让不可能的状态无法表示」。

In one line: currentPage can only be one of four strings, and each page uses && to decide whether it should show.

Draw the transition table first:

FromTriggerTo
homeclick book-buttoncab-options
cab-optionsclick a card’s Selectloading (and write to Context)
loadingautomatic after 1scab-confirmation
cab-confirmationclick confirm-buttonhome

Four transitions, four callbacks. In code that is four setCurrentPage(...) calls, handed down as onBookClick / onSelectCab / onComplete / onConfirm. No child component knows that “pages” exist — each one only knows it has a callback to fire.

Why && and not a ternary: this is one-of-four. Nested ternaries turn into a ? X : b ? Y : c ? Z : W, which is tiring to read and has to be restructured to add a fifth page. Four parallel && lines give you one line per page, so adding a page means adding a line.

Follow-up: “Why not four booleans?” — because booleans permit illegal states. With isLoading and isHome both true the page has two <main> elements, and getByTestId throws when it matches more than one. One string means the illegal state cannot be represented at all — the “make impossible states unrepresentable” idea.

JSXsrc/App.jsx(四个 && 就是状态机)src/App.jsx (four && operators are the state machine)源项目From source
1import { useState } from "react";
2import "./App.css";
3import { AppHeader } from "./components/AppHeader";
4import Home from "./components/Home/Home";
5import CabOptions from "./components/CabOptions/CabOptions";
6import Loading from "./components/Loading/Loading";
7import CabConfirmation from "./components/CabConfirmation/CabConfirmation";
8import { useCabContext } from "./context/CabContext";
9
10const title = "Cab Booking";
11
12const App = () => {
13 const [currentPage, setCurrentPage] = useState("home");
14 const { updateBookedCabDetails } = useCabContext();
15
16 const handleSelectCab = (cab) => {
17 updateBookedCabDetails(cab);
18 setCurrentPage("loading");
19 };
20
21 return (
22 <div className="App">
23 <AppHeader title={title} />
24
25 {currentPage === "home" && (
26 <Home onBookClick={() => setCurrentPage("cab-options")} />
27 )}
28
29 {currentPage === "cab-options" && (
30 <CabOptions onSelectCab={handleSelectCab} />
31 )}
32
33 {currentPage === "loading" && (
34 <Loading onComplete={() => setCurrentPage("cab-confirmation")} />
35 )}
36
37 {currentPage === "cab-confirmation" && (
38 <CabConfirmation onConfirm={() => setCurrentPage("home")} />
39 )}
40 </div>
41 );
42};
43
44export default App;
Source: cab-booking-context/src/App.jsx
§02

为什么 handleSelectCab 在 App 里Why handleSelectCab lives in App

因为它要同时干两件事,而其中一件只有 App 知道Because it has to do two things at once, and only App can do one of them

一句话:选一辆车要写 Context切页面, 而「切页面」这件事只有 App 做得到 ——currentPage 在它手里。

换个问法:CabCard 能不能自己调updateBookedCabDetails技术上能 —— 它也在 Provider 底下,useCabContext() 一样能用。 但它没法切页面, 所以还是得往上抛一个回调。既然回调躲不掉,就把两件事都放在回调里, 别拆成「Card 写数据 + App 切页面」两半。

拆成两半会出什么问题:「选车」这个动作变成 两个组件配合完成的,顺序和完整性没人保证 —— 以后有人在 CabCard 里加个提前 return, 就会出现「页面切了但 Context 没写」, 确认页显示 undefined is on the way

CabCard 有多干净:它只有 onClick={() => onSelectCab(cab)}完全不知道 Context 存在,也不知道有页面这回事。 这种组件最好测、最好复用。

会追问:「这不就是状态提升(lifting state up)吗?」—— 对。规则是:状态放在所有需要它的组件的最近公共祖先。currentPage 四个页面都要用, 公共祖先就是 ApprideHistory 的消费者跨了三层, 提升到 App 还得往下传, 所以它去了 Context。两种手段解决的是同一个问题,只是距离不同。

In one line: picking a cab has to write to Context and switch the page, and only App can do the second one — currentPage lives there.

Put it another way: could CabCard call updateBookedCabDetails itself? Technically yes — it is under the Provider too, so useCabContext() works. But it cannot switch the page, so it still needs to hand a callback upward. Since the callback is unavoidable, put both jobs inside it rather than splitting into “Card writes data, App switches page”.

What splitting costs you: “pick a cab” becomes an action two components perform together, and nothing guarantees order or completeness. Somebody adds an early return in CabCard later and you get “page switched but Context never written”, so the confirmation page reads undefined is on the way.

Notice how clean CabCard is: it only has onClick={() => onSelectCab(cab)} and knows nothing about Context and nothing about pages. That is the easiest kind of component to test and to reuse.

Follow-up: “Isn’t this just lifting state up?” — yes. The rule is: state belongs in the closest common ancestor of every component that needs it. All four pages need currentPage, and their common ancestor is App. The consumers of rideHistory sit three levels apart, so lifting it to App would still mean drilling it down — which is why it went into Context instead. Both tools solve the same problem; they differ in distance.

JSXsrc/components/Home/Home.jsx(只往上抛回调)src/components/Home/Home.jsx (it only passes the callback up)源项目From source
1import RideHistory from "./RideHistory";
2
3const Home = ({ onBookClick }) => {
4 return (
5 <main className="home-container">
6 <section className="hero-card">
7 <p className="eyebrow">HackerRide</p>
8 <h2>Book a Safe Ride with HackerRide</h2>
9 <p className="hero-copy">
10 Choose a cab, wait for confirmation, and review your latest rides.
11 </p>
12 <button
13 type="button"
14 className="primary-button"
15 data-testid="book-button"
16 onClick={onBookClick}
17 >
18 Book a Cab
19 </button>
20 </section>
21
22 <RideHistory />
23 </main>
24 );
25};
26
27export default Home;
Source: cab-booking-context/src/components/Home/Home.jsx
练习Practice

动手做Get your hands on it

填空只是过渡。真正掌握的标准,是在没有答案的时候从头写出来 —— 所以做完 L2 之后一定要往 L3、L4 走。Filling blanks is a stepping stone. The real bar is writing it from nothing, so once L2 is comfortable, push on to L3 and L4.

L1排顺序Order it把一次完整预订的六步排好Put the six steps of one full booking in order
从点「Book a Cab」到历史里出现记录,中间发生了什么?按顺序排。What happens between pressing “Book a Cab” and the record appearing in the history? Put the steps in order.
1setCurrentPage("cab-options")
2updateBookedCabDetails(cab) —— 写 Context 的两个 stateupdateBookedCabDetails(cab) — writes both states in the Context
3setCurrentPage("loading")
4Loading 的 useEffect 里 setTimeout 1000ms 到期The setTimeout of 1000ms inside the useEffect of Loading fires
5onComplete() → setCurrentPage("cab-confirmation")
6onConfirm() → setCurrentPage("home"),首页读到新的 rideHistoryonConfirm() → setCurrentPage("home"), and the home page reads the new rideHistory
L2填空Fill the blanks补齐 App 的状态机Fill in the state machine of App
五个空。注意第 4 个空是这道题最容易写反的地方。Five blanks. The fourth one is the easiest place in this exercise to get backwards.
JSXsrc/App.jsx5 个空5 blanks
1const App = () => {
2 const [currentPage, setCurrentPage] = useState();
3 const { } = useCabContext();
4
5 const handleSelectCab = (cab) => {
6 (cab);
7 setCurrentPage("loading");
8 };
9
10 return (
11 <div className="App">
12 <AppHeader title={title} />
13
14 {currentPage === "home" && (
15 <Home onBookClick={() => setCurrentPage("cab-options")} />
16 )}
17
18 {currentPage === "cab-options" && (
19 <CabOptions onSelectCab={} />
20 )}
21
22 {currentPage === "loading" && (
23 <Loading onComplete={() => setCurrentPage("cab-confirmation")} />
24 )}
25
26 {currentPage === "cab-confirmation" && (
27 <CabConfirmation onConfirm={() => } />
28 )}
29 </div>
30 );
31};
把 5 个空都填上才能检查(还差 5 个)Fill all 5 blanks to check (5 to go)
错例Wrong

初学者常见的几种写法错误Mistakes beginners actually make

下面每一段都是「能编译、但结果不对」或者「一跑就炸」的真实写法。先自己看出问题在哪,再看解释。Every snippet below either compiles and gives the wrong answer, or blows up on the first run. Spot the problem yourself before reading the explanation.

JSX四个 boolean 的下场(示意)Where four booleans lead (illustration)示意Illustrative
1// ✕ 用四个 boolean 表示页面
2const [isHome, setIsHome] = useState(true);
3const [isOptions, setIsOptions] = useState(false);
4const [isLoading, setIsLoading] = useState(false);
5const [isConfirm, setIsConfirm] = useState(false);
6
7const handleSelectCab = (cab) => {
8 updateBookedCabDetails(cab);
9 setIsLoading(true); // 忘了 setIsOptions(false)
10};
11
12// 结果:cab-options 和 loading 同时渲染
13// 测试 3:getByTestId("loading") 能过
14// 但如果两个页面里有同名 testid,就会报
15// "Found multiple elements by: [data-testid=...]"
1// ✕ using four booleans for the page
2const [isHome, setIsHome] = useState(true);
3const [isOptions, setIsOptions] = useState(false);
4const [isLoading, setIsLoading] = useState(false);
5const [isConfirm, setIsConfirm] = useState(false);
6
7const handleSelectCab = (cab) => {
8 updateBookedCabDetails(cab);
9 setIsLoading(true); // setIsOptions(false) was forgotten
10};
11
12// result: cab-options and loading render at the same time
13// test 3: getByTestId("loading") still passes
14// but if the two pages share a testid name, you get
15// "Found multiple elements by: [data-testid=...]"
四个 boolean 有 16 种组合,其中只有 4 种是合法的。 每次切页你得记住「开一个、关一个」,漏关一个就出现两个页面同时在屏幕上
一个字符串 state 只有 4 种取值,setCurrentPage("loading")天然就把别的页面关掉了。 这不是「写法更漂亮」,是把一整类 bug 从可能变成不可能。
Four booleans have 16 combinations, and only 4 of them are valid. On every page change you have to remember to turn one on and one off, and if you forget one, two pages are on the screen at the same time.
A single string state has only 4 possible values, so setCurrentPage("loading") turns the other pages off by itself. This is not about code that reads better. It moves a whole class of bug from possible to impossible.
迁移Transfer

换一道题也能用Works on other problems too

考试不会原题重考。真正能带走的是「看到这种信号 → 伸手去拿这个解法」。The exam will not reuse the same question. What you take away is the reflex: see this signal, reach for that solution.

几个界面互斥地出现Several screens that must never show at the same time
一个字符串 state + 若干 &&,别用多个 booleanOne string state plus a few && checks, not several booleans
一个动作要改状态又要切界面One action changes data and switches the screen
把两件事包进同一个 handler,放在拥有界面状态的那一层Put both jobs in one handler, at the level that owns the screen state
子组件需要触发父组件的状态变化A child component needs to change the parent's state
父组件传回调下去,子组件不碰父的 stateThe parent passes a callback down; the child never touches the parent's state
onClick 里想传参数You want to pass an argument from onClick
() => fn(arg);参数不变就直接传 fn,别多包一层() => fn(arg); with no argument pass fn itself and add no wrapper
这节的要点What to take away
  1. 四个页面用一个 currentPage 字符串管,四个 && 各判一次。One currentPage string controls the four pages, with one && check for each.
  2. 先画转移表:四条转移就是四个回调,代码照抄。Draw the transition table first: the four transitions become four callbacks, and the code follows the table.
  3. handleSelectCab 放在 App 里,因为「切页面」只有 App 做得到。handleSelectCab goes in App, because only App can change the page.
  4. onSelectCab={handleSelectCab} 不能加括号 —— 加了会在渲染时执行并无限重渲染。onSelectCab={handleSelectCab} must have no parentheses. With them the function runs during render and the component re-renders without end.
  5. RideHistory 挂在首页里,所以点完确认回首页就能看到新记录。RideHistory sits on the home page, so when you confirm and come back home the new entry is there.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises2 个,就在这一页上面 —— 别攒着最后一起做2 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lesson按类型分组渲染六张卡Rendering the six cards grouped by type
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: Context 放在哪一层 —— 这道题最容易死的地方Which level the Context goes on — the most common way to fail this task