您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

36KB

FOCUS Architecture — Chapter-12: The View: Dumb by Design

  • Source: /library/FOCUS Architecture/source-file.pdf
  • PDF pages: 290–330
  • Pages without text: none

The View: Dumb by Design In this chapter, you’ll: classify any screen-code snippet as “belongs in the View” or “leaked from another layer,” with a test that fits in one sentence; refactor a screen that adds up totals, decides discounts, and formats currency, until only the event/state pair is left; define renderable state and use the wrong-layer test in your next code review. You’ve debugged a wrong total at eleven at night and found out the math lived inside a widget. The screen looked like the natural place: the value shows up there, so it gets calculated there. This chapter shows why that “natural” spot is the most expensive place in the system for a rule to live, and hands you the alternative: the screen that decides nothing. A screen with no decisions has no rule bugs; at worst, it has a pixel bug. Chapter 11 left you standing at the door of features/tab/tab_view , the first of the slice’s four pieces. That door’s contract has been signed since chapter 10, in the View row of the canonical table: it “fires events” and “renders state,” and it forbids “business rules” and “data access.” Two short cells, and every word in them carries a decision. “Fires events” means the screen’s output is a business-named notice, never a call to a service. “Renders state” means the input arrives ready, with nothing left to calculate. And

the two forbidding cells shut the back doors: no rule inside the screen, no direct data access. This chapter’s path is watching that row turn into code: first the screen that ignores the contract and does everything, then the state that arrives ready, then that same screen shrinking until it obeys. The screen that calculates Rosie’s Coffee Shop’s tab screen, the way almost every screen is born. It receives the raw items, price in cents, and settles the rest on its own: Dart // The screen that does everything for the tab. It works, and that's // the problem. class NaiveTabView extends StatelessWidget { const NaiveTabView({ required this.items, required this.loyaltyPoints, super.key, });

// Each item arrives raw: name and price in cents, no decision // at all. final List<({String name, int priceInCents})> items; // And the customer's points arrive raw too, for the screen to // decide. final int loyaltyPoints; @override Widget build(BuildContext context) { // Adds up the items in a loop: business rule inside build. var totalInCents = 0; for (final item in items) { totalInCents += item.priceInCents; }

// Applies the loyalty discount in an inline if: more rule. if (loyaltyPoints >= 100) { totalInCents = (totalInCents * 90) ~/ 100; } // Formats the currency inside build: a local presentation // decision. final total = “$${(totalInCents / 100).toStringAsFixed(2)}"; // Builds the list and decides, at the button, whether it can pay. return Column( children: [ for (final item in items) Text( “${item.name}: " “$${(item.priceInCents / 100).toStringAsFixed(2)}",

), Text(“Total: $total”), ElevatedButton( onPressed: totalInCents > 0 ? () {} : null, child: const Text(“Pay”), ), ], ); } } A bit over forty lines, and all of them work. The barista sees the items, the total comes out right, the 10% loyalty discount kicks in the moment the customer hits 100 points (the if compares with >= , so an even hundred already qualifies). No manual test fails this screen. The problem isn’t what it shows; it’s what it knows. The first pain shows up when you try to test the sum. The totalInCents loop lives inside build , so checking that 700 + 1195 equals 1895 requires you to instantiate a widget, build a render

tree, and inspect a Text by its string. You pay the price of a UI test to check the addition of two integers. The second pain shows up on the screen next door. The coffee shop has the barista’s screen and the register screen, and both show the same tab’s total. If the math lives inside build , each screen carries its own copy of the loop and the discount if , because build doesn’t care about exports and doesn’t export itself. The tab’s most important business rule now exists in two places that don’t know about each other. The third pain is the second pain’s bill, and it arrives with a date. The day Rosie changed the discount from 10% to 15%, the developer edited the if on the barista’s screen and forgot the one on the register screen; QA (Quality Assurance) opened the app and found three screens with three different totals for the same table, because the end-of-day report carried a third copy of the rule. None of the three was “wrong” in its own code. What was wrong were the copies, and copies are exactly what a screen that calculates manufactures. The state that arrives ready The way out of the three pains is an inversion: instead of the screen receiving raw data and deciding, it receives everything already decided. Renderable state is the data structure the View receives ready to display: the total’s text already formatted, the button enabled or disabled as a boolean, the list already sorted. Nothing to calculate. If it arrived, render it. Notice the hidden test buried in that definition: the field’s type gives away who’s deciding. A double total invites the screen to format it; a String formattedTotal was already formatted by someone else, and the screen just passes it along. Between the two,

renderable state always picks the second, because formatting is deciding how a piece of information looks, and no decision belongs to the screen. State is the input. The output follows the same discipline, and chapter 10 already introduced it from a distance: event as the View’s only output means the screen doesn’t call a service, doesn’t query a repository, and doesn’t decide where to go next; it emits an event with a business name and waits for the next state to arrive. Input and output are the two ends of the same contract, and the tab’s contract fits in one listing: Dart · TypeScript // The tab's renderable data: everything the screen shows arrives // ready. class TabData { const TabData({ required this.items, required this.formattedTotal, required this.canPay, });

// Arrive ALREADY sorted: the screen doesn't sort. final List items; // Ready text (e.g. “$27.50”): the screen doesn't format. final String formattedTotal; // Decision made elsewhere: the screen doesn't compare. final bool canPay; } class TabItem { const TabItem(this.name, this.formattedPrice); final String name; final String formattedPrice; }

// The View's only output: business-named events, chapter 10's // spelling. sealed class TabEvent {} final class AddItem extends TabEvent { AddItem(this.table, this.item); final int table; final String item; } final class RemoveItem extends TabEvent { RemoveItem(this.table, this.item); final int table; final String item;

} final class PayTab extends TabEvent { PayTab(this.table); final int table; } TypeScript writes the same contract with a type and a discriminated union, like chapter 10: TabData becomes a type with readonly fields, and each event becomes a member with a literal field type: “addItem” . The names don’t change from one language to the other, and that’s deliberate: AddItem and TabEvent are the spelling chapter 10 published, and RemoveItem and PayTab follow the same verb-plus-object convention. Notice, too, what the state doesn’t carry: the table. The screen knows which table it is from the navigation context, and it carries the number inside the event; the state only carries what gets drawn. TabItem deserves a second look, because it’s the definition of renderable state in miniature. Chapter 10’s menu item had priceInCents , an integer for a rule to do math with. This one has formattedPrice , a text for the screen to display. Same business item, two different types, because each layer receives the data in the shape its role consumes.

Try it: open https://focus.kodel.com.br/en/dart/12-01 (or https://focus.kodel.com.br/en/ts/12-01): the whole contract in a single file, with a stub that hands back ready states. Delete the formattedTotal field from the state and run it. Prediction: the compiler points straight at the line that renders the total, for free, on the spot; without typed state, that same mistake would be a UI test breaking at 3am, or a customer complaining about a blank total. The other eight languages live at https://focus.kodel.com.br/en/java/12-01, https://focus.kodel.com.br/en/csharp/12-01, https://focus.kodel.com.br/en/go/12-01, https://focus.kodel.com.br/en/php/12-01, https://focus.kodel.com.br/en/python/12-01, https://focus.kodel.com.br/en/kotlin/12-01, https://focus.kodel.com.br/en/swift/12-01, and https://focus.kodel.com.br/en/rust/12-01. The same screen, now dumb No new screen: the refactor takes NaiveTabView from the first section apart, decision by decision, and every decision it loses gets a named destination. The sum leaves first. The totalInCents loop stops existing in the screen, because the total arrives inside the state, in formattedTotal ; who builds that state is the orchestrator’s job, and how it builds it is chapter 13’s business. The discount if leaves next, by the same road: if the total that arrives already carries the discount applied, the screen has no reason to know the 100-point cutoff or the 10% cut. Formatting leaves last, toStringAsFixed and the currency prefix, because ready text travels better than a raw number: “$18.95” displays the same on any screen that receives it, and the

display rule ends up with a single home. Even the button’s decision goes away: canPay arrives as a boolean, compared by no one here. What’s left is this: Dart // The SAME screen, now dumb: renders state and fires events. That's // it. class TabView extends StatelessWidget { const TabView({ required this.table, required this.data, required this.onEmit, super.key, }); final int table; final TabData data;

final void Function(TabEvent event) onEmit; @override Widget build(BuildContext context) { // Builds the list: the items arrive ready and already sorted. return Column( children: [ for (final item in data.items) ListTile( title: Text(item.name), trailing: Text(item.formattedPrice), onLongPress: () => onEmit(RemoveItem(table, item.name)), ), // Passes the total's text along: it arrived ready, it // leaves ready.

Text(“Total: ${data.formattedTotal}"), // Fires the event: the screen's only output. ElevatedButton( onPressed: () => onEmit(AddItem(table, “Cappuccino”)), child: const Text(“Add cappuccino”), ), ElevatedButton( // Enablement already decided elsewhere: the screen // doesn't compare. onPressed: data.canPay ? () => onEmit(PayTab(table)) : null, child: const Text(“Pay”), ), ], ); }

} Look for a calculation in that listing. There isn’t one. No sum, no comparison against a business value, no toStringAsFixed : build turned into a direct translation from TabData to widgets, plus three spots where a barista’s gesture becomes a TabEvent . The screen does exactly the two things the table’s row grants it, and not one more. The same screen in React proves the pattern isn’t Flutter’s alone: TypeScript // The SAME screen, now dumb: renders state and fires events. That's // it. type Props = { readonly table: number; readonly data: TabData; readonly onEmit: (event: TabEvent) => void; }; export function TabView({ table, data, onEmit }: Props) { return (

{/* Builds the list: the items arrive ready and already sorted. */}
    {data.items.map((item) => (
  • onEmit({ type: "removeItem", table, item: item.name }) } > {item.name}: {item.formattedPrice}
  • ))}

{/* Passes the total's text along: arrived ready, leaves ready. */}

Total: {data.formattedTotal}

{/* Fires the event: the screen's only output. */} <button onClick={() => onEmit({ type: “addItem”, table, item: “Cappuccino” }) } > Add cappuccino <button // Enablement already decided elsewhere: no comparison here. disabled={!data.canPay} onClick={() => onEmit({ type: “payTab”, table })}

    Pay
  </button>
</div>

); } The two listings differ on the skin and agree on the skeleton. Flutter’s build returns a widget tree built in plain Dart; React returns elements written in JSX (JavaScript XML), the syntax that mixes markup and expression. Flutter receives its three dependencies through the constructor and emits through onEmit , a handler; React receives the same three through props (short for properties, the values a component receives from outside) and emits through the same kind of callback. Swap the names around and the design is one and the same: TabData comes in, AddItem , RemoveItem , and PayTab go out. If you’re coming from React, one absence should have jumped out at you: there’s no useState in this component. That’s not an oversight. Business state (items, total, can-pay) lives outside the screen and arrives through renderable state; useState stays legitimate for local, pure-UI state, the kind no other layer has any reason to know about: a field’s focus, the scroll position, an animation’s progress. The test is asking whether Rosie cares. She cares about the total; she doesn’t care where the scroll stopped.

Two frameworks don’t make a proof yet; they make a coincidence. The proof is the book’s other eight languages, each in the real framework its readers use at work, all of them coming up next. In each listing, look first for where the state comes in ready. In the first six, look also for where the event goes out named, whether through a callback or a form’s action . The last two, Go and Rust, print only the drawing half: in them the event is born outside the listing, and the text says where. Everything that changes from one to the next is the syntax in the middle. Kotlin, in Jetpack Compose, is Flutter’s next-door neighbor: a function annotated @Composable instead of a class with build , and the same three parameters arriving from outside: Kotlin // The dumb screen: renders state and fires events. That's it. @Composable fun TabView( table: Int, data: TabData, onEmit: (TabEvent) -> Unit, ) { Column {

// Builds the list: the items arrive ready and already sorted. data.items.forEach { item -> Text(“${item.name} ${item.formattedPrice}") } // Passes the total's text along: it arrived ready, it // leaves ready. Text(“Total: ${data.formattedTotal}") // Fires the event: the screen's only output. Button(onClick = { onEmit(AddItem(table, “Cappuccino”)) }) { Text(“Add cappuccino”) } Button( onClick = { onEmit(PayTab(table)) },

// Enablement already decided elsewhere: no comparison // here. enabled = data.canPay, ) { Text(“Pay”) } } } Swift, in SwiftUI, writes the same function as a struct that declares a body : the View is literally a function of the state it receives, and events go out through the same callback. The visible difference is the events enum with associated values, which chapter 10 introduced: the dot before .removeItem is Swift shortening TabEvent.removeItem . Swift // The dumb screen: renders state and fires events. That's it. struct TabView: View { let table: Int

let data: TabData let onEmit: (TabEvent) -> Void var body: some View { VStack { // Builds the list: items arrive ready and already // sorted. List(data.items) { item in HStack { Text(item.name) Spacer() Text(item.formattedPrice) } .onLongPressGesture { onEmit(.removeItem(table: table, item: item.name)) }

} // Passes the total's text along: arrived ready, leaves // ready. Text(“Total: (data.formattedTotal)") // Fires the event: the screen's only output. Button(“Add cappuccino”) { onEmit(.addItem(table: table, item: “Cappuccino”)) } Button(“Pay”) { onEmit(.payTab(table: table)) } // Enablement already decided elsewhere: no comparison // here.

.disabled(!data.canPay) } } } C#, in Blazor, closes out the component-framework group: the markup lives in a .razor file, and the three dependencies arrive through [Parameter] , Blazor’s equivalent of React’s props : C# @* The dumb screen: renders state and fires events. That's it. *@

@* Passes the total's text along: arrived ready, leaves ready. *@

Total: @Data.FormattedTotal

@* Fires the event: the screen's only output. *@ Add cappuccino @* Enablement already decided elsewhere: no comparison here. *@ Pay @code { [Parameter] public int Table { get; set; }

[Parameter] public TabData Data { get; set; } = default!; [Parameter] public Action OnEmit { get; set; } = default!; } The next three languages live on the server, and in them the View changes body without changing contract. In Spring MVC, in Laravel, and in Django, the “screen” is a pair: a controller that converts the HTTP gesture into an event, and a template that repeats the state. The template is dumb by construction, because a template language barely knows how to do math; the controller is the part discipline keeps dumb: it receives the request, assembles the event, and passes it on to the orchestrator, with no rule along the way. The event doesn’t go out through a callback: it goes out through the form’s action , which names the gesture’s route. In Java, with Thymeleaf: Java

Add cappuccino Pay PHP, with Blade, writes the same template in Laravel’s syntax, and the @disabled directive reads the same ready boolean th:disabled read: PHP

    @foreach ($data->items as $item) {{-- Builds the list: items arrive ready and already sorted. --}}

  • {{ $item->name }}: {{ $item->formattedPrice }}
  • @endforeach

{{-- Passes the total's text along: arrived ready, leaves ready. --}}

Total: {{ $data->formattedTotal }}

{{-- Fires the event: the screen's only output. --}} @csrf Add cappuccino

@csrf {{-- Enablement already decided elsewhere: the template doesn't compare a business value, it just reads the ready boolean. --}} <button @disabled(!$data->canPay)>Pay</button

Python, with the Django template, closes out the server-side trio. Notice that all three template languages forbid almost everything on purpose: none of them can add numbers, which is exactly why a business rule in a template isn’t even a temptation; the temptation lives in the controller, and that’s where the dumb View’s discipline does its work. Python
    {% for item in data.items %} {# Builds the list: items arrive ready and already sorted. #}
  • {{ item.name }}: {{ item.formatted_price }}
  • {% endfor %}
{# Passes the total's text along: arrived ready, leaves ready. #}

Total: {{ data.formatted_total }}

{# Fires the event: the screen's only output. #} {% csrf_token %} Add cappuccino {% csrf_token %} {# Enablement already decided elsewhere: the template doesn't compare a business value, it just reads the ready boolean. #} Pay Go skips the framework: html/template ships in the standard library, and the dumb template fits in a constant. The if not .CanPay doesn’t violate the wrong-layer test, and it’s worth understanding why: it doesn’t compare a business value, it just reads a boolean that arrived already decided, exactly like React’s disabled={!data.canPay} . The listing shows only half the design, and there’s no form and no action : Go’s template limits itself to repeating the state. What turns the click into AddItem is the route’s net/http handler, written in plain Go, outside the template. Go // features/tab/tab_view.go // The dumb template lives in an html/template const: it just repeats // what arrived ready in the state. The pay button reads the ready // boolean; no business comparison here. const tabTemplate = `
    {{- range .Items}}
  • {{.Name}}: {{.FormattedPrice}}
  • {{- end}} {{- if not .Items}}
  • (empty tab)
  • {{- end}}

Total: {{.FormattedTotal}}

Pay ` Last, Rust, which pushes the proof to the extreme: the screen doesn’t even need to be graphical. The tab’s View becomes a terminal render loop, a function that draws the state with println! , and the pattern survives intact because it never depended on a widget, on HTML, or on a screen; it only ever depended on the contract: state in, event out. The listing is half the design: the function only prints. The other half is the loop that reads the keystroke and turns it into a TabEvent before calling draw again. Rust

// The terminal's dumb screen: draws what arrived ready, and nothing // else. fn draw(table: u32, data: &TabData) { println!(“+--- Tab: table {table} ---+”); for item in &data.items { println!(“| {} {}", item.name, item.formatted_price); } if data.items.is_empty() { println!(“| (empty tab)"); } println!(“| Total: {}", data.formatted_total); // Enablement already decided elsewhere: no comparison here.

let pay = if data.can_pay { “[P] Pay” } else { “[ ] Pay (unavailable)” }; println!(“| {}", pay); println!(“+-----------------------+”); } Ten languages, and not one syntax repeated: a widget in Dart, JSX in TypeScript, @Composable in Kotlin, body in Swift, .razor in C#, three server templates, a standard-library constant in Go, and a println! in Rust. What repeated was the skeleton: TabData comes in ready in all ten, the event goes out named in the eight that have somewhere to emit it, and not one listing did any math. RemoveItem only shows up in Dart, React, and Swift, because only in those three did the remove gesture fit inside the listing; in the other seven it stays in the contract and waits for the gesture that fires it. That invariance is what chapter 10’s table called framework as a replaceable detail.

What’s left unanswered is where the state comes from. The full answer is chapter 13; for now, the whole cycle fits in a diagram with a closed box in the middle: The diagram has two boxes and two arrows. The screen sends TabEvent to the orchestrator; the orchestrator, drawn as a closed box with a dashed border, returns TabData to the screen. What happens inside (where the items come from, who adds them up, who formats them) the diagram hides on purpose, because chapter 13 opens that box unhurried. Until that box opens, this chapter’s code uses a stand-in with an honest name: OrchestratorStub takes any TabEvent and returns the next TabData from a pre-built sequence, written by hand, with no calculation at all. It exists only so the screen has something to render in the playgrounds; the real orchestrator is born in chapter 13. Try it: open https://focus.kodel.com.br/en/dart/12-02 (or https://focus.kodel.com.br/en/ts/12-02) and tap “Add cappuccino.” Prediction: the list gains the cappuccino and the total becomes $18.95 without the screen adding anything up, because the stub handed back the sequence’s second state; tap again and the tab empties out, the total zeroes, and the “Pay” button disables itself, because canPay arrived false and the screen never compared anything. The same dumb screen exists in the real framework of each of the other eight languages, at https://focus.kodel.com.br/en/java/12-02, https://focus.kodel.com.br/en/csharp/12-02,

https://focus.kodel.com.br/en/go/12-02, https://focus.kodel.com.br/en/php/12-02, https://focus.kodel.com.br/en/python/12-02, https://focus.kodel.com.br/en/kotlin/12-02, https://focus.kodel.com.br/en/swift/12-02, and https://focus.kodel.com.br/en/rust/12-02. Does this belong in the View? The refactor gave you the instinct; what’s missing is the test you can say out loud. The wrong-layer test is this: if the screen compares business values in an if , that if is in the wrong layer. Comparison is the cheapest symptom to catch, and wherever there’s a business comparison there’s a decision, and the table from chapter 10 forbids decisions in the View. Apply the test to five snippets that show up in every codebase, all of them from the coffee shop. Formatting the pickup date inside the screen, a DateFormat(“MM/dd”).format(pickup) in the middle of build ? Leaked. Date formatting is a presentation decision with a rule hiding inside it (time zone, locale, “today” versus “07/18”), and a duplicated decision diverges the same way the three totals diverged. The date arrives in the state as ready text. Sorting the item list inside the screen, an items.sort() before the loop? Leaked. The sorting criterion (alphabetical? most recent first? drinks before food?) is the tab’s business rule, and the definition of renderable state already said it: the list arrives sorted. Deciding the inventory alert’s color, a stock < minimum ? red : green ? Leaked, and this is the case that fools people most, because color looks like the screen’s business. Comparing against the

minimum is the rule; what the screen can do is map an enum that arrived in the state ( alert: critical ) to the platform’s color. Mapping the appearance of a ready value is the View’s job; comparing to produce that value isn’t. Passing the total’s text to the widget, the listing’s Text(“Total: ${data.formattedTotal}") ? View. There’s no decision at all: the text came in ready and went out ready. Firing PayTab when the barista taps the button? View, and it’s its heart: turning a gesture into a business-named event is exactly the “fires events” from the table. Five verdicts, one pattern: ask who’s deciding. If the answer is “the screen,” the code changes address. The critique: bloated state The most common objection to all this deserves to be stated in full before it gets answered: “A dumb View bloats the state and moves formatting to a place it doesn’t belong. Formatting currency is presentation; presentation is the screen’s job; a TabData full of ready-made strings is a model polluted with visual detail.” The answer starts by taking the premise apart. Formatting looks cosmetic and is a decision: choosing a comma or a period, two decimal places or none, “$” before or after the number, is choosing how the business presents itself, and the coffee shop already paid to find out what a duplicated decision does. When formatting lived in the screens, it was three copies and three totals in QA. Moving it to whoever builds the state costs once: a String field instead of a double , one formatting function with a single owner. Leaving it in the screens costs every release, in every new screen that copies the rule, in every UI test that climbs

a widget tree to check a comma. Who formats is the orchestrator,
while building the state, with a helper function it calls and that
any test calls too, without ever booting a screen.
This trade isn’t this book’s invention. Michael Feathers
documented it in 2002, in the paper “The Humble Dialog Box”:
keep the dialog humble, with no intelligence of its own, and
move the decisions into a class you can test without a UI. Martin
Fowler cataloged the same idea in 2006 under the name “Passive
View”: the screen reduced to a passive relay, updated from
outside, with almost nothing left to get wrong. That’s more than
two decades of people pulling code out of the most expensive
place to test, and the most expensive place to test is still the
screen.
Here’s my position, no fence-sitting: a widget test is no place for
a business rule. I don’t write a test that climbs a render tree to
check whether a 10% discount came out right, and I get
suspicious of any suite where the UI tests are the ones that break
most, because that’s a rule living in the screen giving itself away.
With a dumb View, the rule gets a pure function test, and the
View is left with so little inside it that there’s almost nothing left
to test in it: what’s left is checking that state turns into widgets
and gestures turn into events, and the compiler plus half a dozen
thin tests cover that.
Pitfalls
The first pitfall is a business if disguised as “just a bit of
formatting.” The usual disguise: Text(total, style: points >= 100 ? green
black) . What goes wrong: the 100-point cutoff just got a second home, and the day it becomes 120 points someone will update the rule and forget the color, and the screen will paint green on a total that no longer earns a discount. Why: the comparison looks

like style because the result is a color, but the operand is a business value, and the wrong-layer test fails on the operand, not the result. How to get out: the state hands over the decision already made ( highlightTotal: true , or an alert enum), and the screen only maps the value to the platform’s style. The second pitfall is reaching straight into the repository from the screen, “just this once,” a TabRepository().fetchTab(table) inside initState because the deadline is tight. What goes wrong: the screen becomes the owner of both the rule and the IO at once; it decides when to fetch, what to do with a failure, and how to store the result, and every one of those decisions turns untestable without booting a UI and faking a network. Why: the shortcut punches through both of the View row’s prohibitions at once, and sets the precedent the next screen copies; chapter 15 hasn’t even arrived and the repository already has a client it shouldn’t have. How to get out: the screen fires a load event (or the orchestrator listens to navigation, chapter 13 shows both ways) and renders whatever state comes back, including the error one, which also arrives ready. Q&A If the screen can’t calculate, who does? Chapter 13’s orchestrator, which receives the event, triggers whoever knows the rule, and publishes the new state. This chapter kept it as a closed box on purpose: to the View, it’s just “the place the state comes from,” and that ignorance is exactly what keeps the screen replaceable. What about form validation? Can the email field turn red while I type? Immediate typing feedback can live in the screen as long as it’s a shape check (“does this look like an email?”), with no business value involved. Validation that decides (“does this coupon exist? is this email already

registered?”) is a rule: it fires an event and comes back in the state. When in doubt, apply the test: what value is the if comparing against? Can local focus and scroll state stay put? Yes, and it should live in the screen: focus, scroll, and animation are details no other layer has any reason to know about. The test is the same one from the refactor section: if Rosie doesn’t care, it’s the screen’s. Quick tip Open your most complex screen’s file and run your editor’s search three times: if ( , + , and format . Every hit that compares, adds, or formats a business value is a candidate to change address, and the search costs less than a whole code review. Quick reference Situation Fix Formatting currency, date, or text leaked: arrives ready ( formattedTotal ) Sorting or filtering the list leaked: the list arrives sorted in the state Comparing business values to decide a color leaked: the state hands over the enum Enabling or disabling a button ready boolean in the state ( canPay )

Passing ready text to the widget View A tap turning into an event ( PayTab ) View: “fires events” Field focus, scroll position, animation View: local, pure-UI state Fetching data “just this once” in the screen leaked: reread the second Pitfall Exercises

  1. Classify each snippet below as “belongs in the View” or “leaked from another layer,” using the wrong-layer test; the answer key is in exercise 3. (a) Text(data.customerName) ; (b) if (tab.items.length > 10) showFullTableWarning() ; (c) onPressed: () => onEmit(RemoveItem(table, item.name)) ; (d) priceInCents / 100 inside a price widget; (e) an AnimationController driving the item panel’s opening.
  2. The coffee shop’s pickup counter screen receives the raw order list and does three things: sorts by promised time, marks the late ones red by comparing against the clock, and formats the time as “HH:mm”. Refactor it on paper: sketch the PickupCounterState (which fields? which types?) and the events the screen fires when the attendant taps an order. When you’re done, check: did any if with a business value survive in the screen?
  3. Answer key for exercise 1: (a) View, ready text passed along; (b) leaked, it compares a business quantity to decide something; (c) View, a gesture turning into an event; (d) leaked, money arithmetic is both math and formatting; (e)

View, animation is local UI state. Now the open challenge: grab a real screen from one of your own projects, run the Quick tip on it, and count how many business decisions you find; then write the renderable state that would leave that screen dumb. Tip 12 If the screen decides, you don’t have a View: you have a rule hiding where it’s most expensive to test. Next chapter: the closed box opens. Chapter 13 builds the orchestrator that receives AddItem and returns ready TabData : event in, state out, and you’ll see what happens in between.

Powered by TurnKey Linux.