1(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[177],{2302:(e,t,n)=>{"use strict";n.d(t,{A:()=>d});var a=n(2115);let o=function(){for(var e=arguments.length,t=Array(e),n=0;n<e;n++)t[n]=arguments[n];return t.filter((e,t,n)=>!!e&&""!==e.trim()&&n.indexOf(e)===t).join(" ").trim()},s=e=>e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),i=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(e,t,n)=>n?n.toUpperCase():t.toLowerCase()),r=e=>{let t=i(e);return t.charAt(0).toUpperCase()+t.slice(1)};var h={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};let l=e=>{for(let t in e)if(t.startsWith("aria-")||"role"===t||"title"===t)return!0;return!1},c=(0,a.forwardRef)((e,t)=>{let{color:n="currentColor",size:s=24,strokeWidth:i=2,absoluteStrokeWidth:r,className:c="",children:d,iconNode:u,...p}=e;return(0,a.createElement)("svg",{ref:t,...h,width:s,height:s,stroke:n,strokeWidth:r?24*Number(i)/Number(s):i,className:o("lucide",c),...!d&&!l(p)&&{"aria-hidden":"true"},...p},[...u.map(e=>{let[t,n]=e;return(0,a.createElement)(t,n)}),...Array.isArray(d)?d:[d]])}),d=(e,t)=>{let n=(0,a.forwardRef)((n,i)=>{let{className:h,...l}=n;return(0,a.createElement)(c,{ref:i,iconNode:t,className:o("lucide-".concat(s(r(e))),"lucide-".concat(e),h),...l})});return n.displayName=r(e),n}},3073:(e,t,n)=>{"use strict";n.d(t,{Header:()=>f});var a=n(5155),o=n(6874),s=n.n(o),i=n(2302);let r=(0,i.A)("chevron-down",[["path",{d:"m6 9 6 6 6-6",key:"qrunsl"}]]);var h=n(9143);let l=(0,i.A)("x",[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]]),c=(0,i.A)("menu",[["path",{d:"M4 5h16",key:"1tepv9"}],["path",{d:"M4 12h16",key:"1lakjw"}],["path",{d:"M4 19h16",key:"1djgab"}]]);var d=n(8999),u=n(2115);[{id:"full-stack-background-jobs",title:"The Request Ended. The Work Did Not.",excerpt:"Emails, exports, webhooks, and slow integrations should not keep an HTTP request open. A queue helps only when retries, duplicate delivery, job state, and the database handoff are designed explicitly.",tags:["Full Stack","Background Jobs","Queues","Reliability","Full-Stack Field Guide"],date:"Oct 04, 2026",readTime:"10 min read",coverImage:"/blog/full-stack-background-jobs/cover.png",published:!1,content:'\nThe order exists. Payment succeeded. The database transaction committed.\n\nNow the API waits three more seconds for an email provider to accept the confirmation message.\n\nSometimes it waits ten. Sometimes the provider times out after doing the work, so the backend does not know whether the email was accepted. The user sees a failed checkout and presses the button again, even though the expensive part already succeeded.\n\nThe email was not part of the request\'s answer. We made it part of the request\'s lifetime.\n\nThat is the boundary background jobs are meant to fix. The API completes the durable work needed to answer the user, records what still has to happen, and returns. A worker continues separately.\n\nSimple in one sentence. Much less simple once "separately" has to remain reliable.\n\n## What belongs outside the request\n\nGood background-job candidates share a few traits:\n\n- the work is slow or has unpredictable latency;\n- the user does not need its final result before continuing;\n- it calls an external service that may be unavailable;\n- it can be retried safely;\n- it may need independent scaling.\n\nConfirmation emails fit. So do image processing, CSV exports, webhook delivery, search indexing, analytics enrichment, and invoice generation.\n\nCreating the order itself does not fit. The API should not tell the browser "order created" before the order is durably recorded. Queueing the core write would turn a clear result into "we accepted your wish and may create it later."\n\nSometimes that asynchronous product is intentional. Then the API should say so with a job or operation id and a pending status. The important part is not pretending pending work is complete.\n\n## A queue separates acceptance from execution\n\nThe request path becomes:\n\n```text\nbrowser\n -> create order in PostgreSQL\n -> enqueue confirmation job\n -> return order\n\nworker\n -> receive job\n -> send email\n -> mark job complete\n```\n\nThe queue buffers work between producers and consumers. The API can accept jobs quickly. Workers process them at the speed the downstream system allows. If email slows down, checkout does not have to slow down with it.\n\nThat buffer also lets workers scale independently. Ten image processors can consume from the same queue during a spike, then scale back later.\n\nBut a queue is not just an array somewhere on the server. It needs durable messages, acknowledgement, retry behavior, visibility into pending work, and a strategy for jobs that never succeed.\n\n## The database-to-queue gap\n\nHere is a subtle failure:\n\n```typescript\nconst order = await database.orders.create(input);
1\nawait queue.publish(\'order-confirmation\', { orderId: order.id });\n\nreturn order;\n```\n\nThe database write succeeds. The process crashes before publishing the message. The order exists, but no confirmation job was recorded.\n\nReversing the order does not help:\n\n```typescript\nawait queue.publish(\'order-confirmation\', { orderId });\nawait database.orders.create(input);\n```\n\nNow the worker may receive a job for an order that does not exist because the database write failed.\n\nTwo reliable systems do not automatically form one atomic operation.\n\nThe transactional outbox pattern solves this by writing the order and an outbox record in the same database transaction:\n\n```sql\nBEGIN;\n\nINSERT INTO orders (id, customer_id, status)\nVALUES ($1, $2, \'confirmed\');\n\nINSERT INTO outbox_events (id, type, payload, created_at)\nVALUES ($3, \'order.confirmed\', $4, now());\n\nCOMMIT;\n```\n\nA separate publisher reads unsent outbox rows, publishes them to the queue, and marks them as sent. If it crashes after publishing but before marking the row, it may publish the same event again.\n\nThat is acceptable only if the consumer handles duplicates.\n\n## At-least-once means duplicates are normal\n\nMost practical queue systems give us at-least-once delivery: a job will be delivered, but under failure it may be delivered more than once.\n\nThe worker receives a job, sends the email, then crashes before acknowledging completion. The queue cannot know the email was sent, so it delivers the job again.\n\nExactly-once delivery is an attractive phrase. End-to-end exactly-once effects across a queue, worker, database, and external email provider are much harder than the phrase suggests.\n\nDesign the job to be idempotent instead.\n\nGive the effect a stable identity:\n\n```typescript\ntype SendOrderConfirmationJob = {\n jobId: string;\n orderId: string;\n templateVersion: number;\n};\n```\n\nBefore sending, record or check whether that specific confirmation has already been completed. If the email provider supports an idempotency key, send the stable job id there too. If it does not, decide whether an occasional duplicate email is acceptable or whether stronger local coordination is worth the complexity.\n\nIdempotency is not "ignore every repeated order id forever." A resend requested by support is a new intent and should have a new job identity.\n\n> A retry is the same intent attempted again. A new action deserves a new identity.\n\nThat is the same rule we used for checkout requests. Queues make it impossible to avoid thinking about it.\n\n## Retry only failures that may change\n\nA timeout from an email provider may succeed on the next attempt. An invalid recipient address will not become valid because we tried it twelve times.\n\nClassify failures:\n\n- **Transient:** timeout, connection reset, provider rate limit, temporary outage.\n- **Permanent:** invalid input, unsupported format, missing required data.\n- **Unknown:** the remote side may have completed the operation before the connection failed.\n\nTransient failures get retries with exponential backoff and jitter. Permanent failures stop immediately and become visible. Unknown outcomes need idempotency or reconciliation before retrying blindly.\n\n```typescript\nconst delayMs = Math.min(60_000, 1000 * 2 ** attempt);\nconst jitterMs = Math.floor(Math.random() * 500);\n\nscheduleRetry(job, delayMs + jitterMs);\n```\n\nBackoff gives the dependency time to recover. Jitter prevents thousands of failed jobs from retrying in perfect synchronization and creating another outage the moment the provider returns.\n\nA maximum attempt count is not enough by itself. A job that gives up needs somewhere to go and someone to notice.\n\n## Dead letters are unfinished product work\n\nAfter repeated failure, a job may move to a dead-letter queue. That name sounds final. It should not mean invisible.\n\nThe job still represents a promise: send a confirmation, generate an export, deliver a webhook. Moving it aside protects healthy work from one poison message, but the product obligation remains.\n\nFor each job type, decide:\n\n- who is alerted;\n- what context is safe to inspect;\n- whether the job can be replayed;\n- whether the user should see a failed status;\n- when the underlying operation needs manual recovery.\n\nA dashboard that proudly shows "queue healthy" while 4,000 jobs sit dead is measuring the machinery instead of the outcome.\n\nQueue depth, oldest-job age, processing duration, retry count, and terminal failures tell a more useful story than worker CPU alone.\n\n## Job payloads should be small references\n\nIt is tempting to put the complete order, customer, and rendered email content into the message. Then the worker has everything it needs.\n\nIt also freezes a large data snapshot inside the queue, exposes more sensitive information, and makes schema changes harder. A delayed
1job may execute days later with a payload produced by old code.\n\nPrefer stable identifiers and intentional snapshots:\n\n```typescript\ntype GenerateInvoiceJob = {\n jobId: string;\n orderId: string;\n invoiceVersion: number;\n};\n```\n\nThe worker loads current durable data by `orderId`. If a value must reflect the moment of purchase, such as the charged price, that snapshot already belongs in the order model. The message does not need to invent another source of truth.\n\nThere are exceptions. A webhook event may intentionally preserve the event-time payload. The principle is to choose snapshot semantics, not accidentally serialize half the database into every job.\n\n## Workers need deployment compatibility too\n\nA queued message can outlive the code that produced it.\n\nDeploy version two of a worker while version-one messages are still waiting, and the new worker must understand the old payload. Roll back the producer, and old code may start publishing the previous shape again.\n\nThis is the migration problem from the previous article, now applied to messages.\n\nUse additive payload changes, sensible defaults, and explicit versions when semantics change. Do not rename a required field and assume the queue emptied during deployment. Measure that it did, or support both versions.\n\nLong-running jobs also need graceful shutdown. When a worker receives a termination signal during deployment, it should stop accepting new work, finish or safely release the current job, acknowledge only after completion, and exit within the platform\'s deadline.\n\nOtherwise every deployment creates retries by killing work halfway through.\n\n## The frontend needs a model for pending work\n\nSome background work is invisible to the user. An analytics enrichment job does not need a progress bar.\n\nAn export does.\n\nThe API can return `202 Accepted` with an operation id:\n\n```json\n{\n "operationId": "export_7f2a",\n "status": "queued"\n}\n```\n\nThe frontend polls an operation endpoint, subscribes through server-sent events or WebSockets, or lets the user leave and returns the finished export through notifications.\n\nThe important state is not only loading and done. It may be queued, running, retrying, completed, failed, or cancelled. Those states belong to the product contract even if a queue implementation uses different internal terminology.\n\nDo not show an eternal spinner because the worker died. Persist operation state somewhere durable, define a timeout or terminal failure, and give the user a next action.\n\nAsync architecture becomes good UX only when uncertainty is visible without becoming alarming.\n\n## Backpressure is a product decision\n\nIf requests create work faster than workers can finish it, queue depth grows. The queue is doing its job, but the delay may eventually violate the product promise.\n\nWe can add workers until a downstream limit stops us. We can prioritize urgent jobs. We can reject or postpone new work. We can reduce expensive processing. What we cannot do is treat an ever-growing queue as healthy because no messages were lost.\n\nFor confirmation email, five minutes may be embarrassing but survivable. For a fraud check that gates order fulfilment, five minutes may block the business. "How old can the oldest job become?" is a product question with an operational metric attached.\n\nQueues buy time. They do not create capacity.\n\n## Keep the promise smaller than the machinery\n\nBackground jobs are useful because they let an HTTP request end before every side effect is complete. That separation improves latency and isolates unreliable dependencies.\n\nBut the queue is not the guarantee. The guarantee comes from the whole path:\n\n- durable intent recorded with the business change;\n- messages published despite process failure;\n- consumers safe under duplicate delivery;\n- retries limited to failures that may recover;\n- dead jobs visible and recoverable;\n- payloads compatible across deployments;\n- product states that admit work is still pending.\n\nOnce those pieces are explicit, a queue stops being mysterious infrastru
1cture. It becomes a controlled handoff between "the user can continue" and "the system still owes some work."\n\nThe next part of this guide moves back to the public edge: domains, DNS, HTTPS, and reverse proxies, and how a name typed into a browser reaches the right application without exposing every internal service directly.\n\nIf your application returns success before all the work is finished, write down what it still owes. That list is the beginning of the background-job design, not an implementation detail after it.\n'},{id:"full-stack-redis-without-magic",title:"Redis Is Fast. Your Data Can Still Be Wrong.",excerpt:"Redis can make an application feel dramatically faster, but speed is only useful when ownership, expiration, invalidation, and failure behavior are explicit. Otherwise the cache becomes a second truth nobody can explain.",tags:["Full Stack","Redis","Caching","Architecture","Full-Stack Field Guide"],date:"Oct 02, 2026",readTime:"9 min read",coverImage:"/blog/full-stack-redis-without-magic/cover.png",published:!1,content:'\nThe product page loads in 900 milliseconds. Someone adds Redis. Now it loads in 70.\n\nThat feels like the end of the story.\n\nThen a price changes in PostgreSQL and the product page keeps showing the old value. One user sees the new price, another sees the cached one, and checkout has to decide which reality to charge.\n\nRedis did not create the inconsistency. We did, the moment we copied data without deciding how that copy becomes stale.\n\nThis is the useful way to think about Redis: not as a faster database, and not as a performance switch. It is a fast, shared, in-memory data structure server. The moment we put something there, we need to know whether it is a cache, a session, a counter, a lock, a queue, or the actual source of truth.\n\nThose jobs have different failure rules. Mixing them is where the magic starts, and magic is difficult to debug.\n\n## Start with one slow read\n\nOur checkout needs a product summary before creating an order. The database query joins current price, availability, and a few display fields. It is read frequently and changes much less often than it is requested.\n\nThat is a reasonable cache candidate.\n\nThe common cache-aside flow looks like this:\n\n```typescript\nasync function getProductSummary(productId: string) {\n const key = `product-summary:${productId}`;\n const cached = await redis.get(key);\n\n if (cached) {\n return ProductSummary.parse(JSON.parse(cached));\n }\n\n const product = await productRepository.getSummary(productId);\n\n await redis.set(key, JSON.stringify(product), { EX: 300 });\n\n return product;\n}\n```\n\nRead Redis first. On a miss, read PostgreSQL, cache the result for five minutes, and return it.\n\nThe database remains the source of truth. Redis holds a disposable copy. If the cache is flushed, the application gets slower while it warms again, but it does not forget what a product costs.\n\nThat last sentence is the design test I use most often:\n\n> If deleting Redis destroys the truth, you are not using it as a cache.\n\nThere are valid systems where Redis owns important data. They need persistence, replication, backup, and recovery designed accordingly. That is a deliberate database choice, not "we already had Redis running."\n\n## A TTL is not an invalidation strategy\n\nThe five-minute expiration prevents the entry from living forever. It does not tell us whether five minutes of stale pricing is acceptable.\n\nFor a blog post list, it may be. For inventory, probably not. For authorization, almost certainly not.\n\nTime-to-live is a business decision disguised as a number. It should come from how long the system can tolerate being wrong, not from a familiar round value copied from another cache.\n\nWhen a product price changes, we have a few options.\n\nWe can delete the cache entry immediately after the database update:\n\n```typescript\nawait database.transaction(async (transaction) => {\n await transaction.products.updatePrice(productId, priceCents);\n});\n\nawait redis.del(`product-summary:${productId}`);\n```\n\nThe next read repopulates it. This is simple and often enough.\n\nBut notice the gap: the database commit can succeed and the Redis deletion can fail. The stale entry then remains until its TTL expires.\
1n\nWe can reduce the risk with retries or publish an invalidation event through an outbox. We can update the cache directly, but then we must decide what happens if the database succeeds and the cache write fails, or the other way around.\n\nThere is no universal "keep it in sync" command. There is only a chosen tolerance for stale data and a recovery path when the two writes do not complete together.\n\n## Cache keys are part of the schema\n\nThis key looks harmless:\n\n```text\nproduct-summary:42\n```\n\nThen the response shape changes. Old application instances write one JSON structure, new instances expect another, and both use the same key.\n\nThe failure looks like an API contract problem because it is one. The cache has a data contract too.\n\nVersion keys when the shape changes incompatibly:\n\n```text\nproduct-summary:v2:42\n```\n\nInclude every dimension that changes the answer: tenant, locale, currency, permissions, or feature variant. If Canadian and US prices share one key, Redis will return the wrong answer extremely quickly.\n\nAt the same time, avoid encoding accidental details into keys. A stable key convention should be readable, bounded, and owned by one module. Scattered string construction creates the same drift as scattered query keys on the frontend.\n\n## Fast misses can overwhelm the database\n\nSuppose a popular product\'s cache entry expires while 2,000 users are viewing it.\n\nAll 2,000 requests miss. All 2,000 query PostgreSQL. The cache was supposed to protect the database, but its expiration creates a traffic spike instead.\n\nThat is a cache stampede.\n\nOne mitigation is request coalescing: the first request acquires a short-lived right to refresh, while the others wait briefly or receive a slightly stale value. Another is adding random jitter to expiration times so thousands of related keys do not disappear in the same millisecond.\n\n```typescript\nconst baseTtlSeconds = 300;\nconst jitterSeconds = Math.floor(Math.random() * 60);\n\nawait redis.set(key, value, {\n EX: baseTtlSeconds + jitterSeconds,\n});\n```\n\nFor data where brief staleness is acceptable, stale-while-revalidate is often a pleasant trade: return the old value immediately and let one worker refresh it in the background.\n\nThe right choice depends on what being stale means. A product description can lag. A stock reservation cannot casually do so.\n\n## Sessions are not ordinary cache entries\n\nRedis is also commonly used for server-side sessions.\n\nThe browser stores an opaque session id in a secure cookie. Redis maps that id to session state. Every application instance can read it, so a user does not lose their session when the load balancer sends the next request to another server.\n\nThis solves the in-memory-session problem from the first article in the series. It also raises the stakes of Redis failure.\n\nIf product cache entries disappear, the database can refill them. If all sessions disappear, everyone may be logged out.\n\nSame technology, different product consequence.\n\nSession data needs an intentional lifetime, secure cookie settings, rotation after privilege changes, and a failure policy. It should not contain a convenient copy of the entire user record that silently outlives permission changes.\n\nStore the minimum needed to identify the session and enforce its lifecycle. Redis being fast is not an invitation to stop modelling identity carefully.\n\n## Counters fit Redis naturally\n\nSome jobs align especially well with Redis because it can update values atomically.\n\nRate limiting is a good example. Increment a counter for one user or IP, set an expiration window, and reject requests after the limit:\n\n```typescript\nconst key = `rate-limit:create-order:${userId}`;\nconst attempts = await redis.incr(key);\n\nif (attempts === 1) {\n await redis.expire(key, 60);\n}\n\nif (attempts > 10) {\n throw new TooManyRequestsError();\n}\n```\n\nProduction implementations usually use a Lua script or a dedicated command pattern so increment and expiration are atomic, and more advanced limits may use sliding windows or token buckets. But the shape explains why Redis fits: a small shared counter, updated frequently, with a natural expiration.\n\nThis is not a replacement for abuse protection at the CDN or gateway. It is an application-aware layer: ten checkout attempts may mean something different from ten product-page reads.\n\n## Distributed locks are easy to overtrust\n\nRedis can coordinate a short-lived lock between application instances. That can be useful for preventing duplicate cache refreshes or ensuring only one scheduler starts a non-critical task.\n\n
1It is more dangerous when the lock is treated as proof that a financial or inventory operation happened exactly once.\n\nProcesses pause. Networks partition. Lock leases expire. A client may continue working after it no longer owns the lock. Correct distributed locking requires careful ownership tokens, expiration, and usually fencing at the resource being protected.\n\nFor shared business truth, I prefer database constraints, idempotency keys, and atomic database updates whenever they can express the rule. A Redis lock can reduce duplicate work. It should not be the only wall protecting an invariant the database can enforce directly.\n\n## Decide what happens when Redis is unavailable\n\nEvery Redis use needs a failure answer.\n\n- Product cache unavailable: read from PostgreSQL, perhaps with traffic protection.\n- Rate limiter unavailable: fail open or fail closed, depending on the endpoint and threat.\n- Session store unavailable: reject authenticated operations rather than invent identity.\n- Duplicate-work lock unavailable: skip the optimization or pause the job.\n\nOne blanket fallback is not honest because these features carry different risks.\n\nAlso watch the fallback itself. If Redis goes down and every request immediately hits PostgreSQL, the database receives the full traffic load at the worst possible moment. Timeouts, connection-pool limits, and circuit breakers keep one dependency failure from becoming two.\n\nThe frontend experiences this as latency and uncertainty. A cache outage may not return a clean error; it may make requests slow enough that users retry, which increases traffic further. Pending states need time limits, retries need idempotency, and "try again" should not mean "create another order."\n\n## Cache only after you know the expensive path\n\nRedis is not the first fix for every slow endpoint.\n\nSometimes the query lacks an index. Sometimes the API fetches the same data four times. Sometimes a huge response is serialized on every request. Sometimes the frontend asks for data it does not need.\n\nCaching an inefficient path can hide it until the cache misses under load.\n\nMeasure the request. Find the expensive operation. Decide how stale its result may be. Choose the cache ownership and invalidation rule. Then add Redis.\n\nThat order makes Redis wonderfully boring. It is a fast copy, a short-lived session, or an atomic counter with an explicit job. Not a mysterious red box that "handles performance."\n\nThe next article follows work that should not keep an HTTP request open at all: confirmation emails, exports, image processing, and other jobs that need to finish after the user already has a response.\n\nIf your Redis instance disappeared for ten minutes, which user-visible promise would break first? That answer is a much better description of your Redis architecture than the list of commands your code uses.\n'},{id:"full-stack-zero-downtime-migrations",title:"The Database Changed Before the Deployment Finished",excerpt:"A schema migration and an application deployment do not happen at one instant. Zero-downtime changes work when old code, new code, and the database are allowed to coexist for a while.",tags:["Full Stack","Database","PostgreSQL","Migrations","Full-Stack Field Guide"],date:"Sep 30, 2026",readTime:"9 min read",coverImage:"/blog/full-stack-zero-downtime-migrations/cover.png",published:!1,content:'\nRenaming a database column looks like one line of SQL.\n\n```sql\nALTER TABLE orders\nRENAME COLUMN total_cents TO total_amount_cents;\n```\n\nThe change is correct. The new name is clearer. The migration takes almost no time on a small table.\n\nAnd it can still break production before the new application finishes deploying.\n\nThe problem is not the rename itself. The problem is that a deployment is not one instant. For a while, old application instances and new application instances may run at the same time. Requests continue arriving. Background jobs keep working. Someone may roll the application back while the database stays exactly where the migration left it.\n\nThat overlap changes the question.\n\nWe are no longer asking, "Does the new code work with the new schema?" We are asking whether every version that can still receive traffic works with the database as it exists right now.\n\n## Deployment is a period of mixed reality\n\nImagine three application instances behind a load balancer.\n\nThe deployment replaces them one at a time:\n\n```text\nminute 0: old, old, old\nminute 1: new, old, old\nminute 2: new, new, old\nminute 3: new, new, new\n```\n\nIf the migration renames `total_cents` before minute one, every old instance immediately starts querying a column that no longer exists.\n\nIf the migration runs after minute three, every new instance may start querying `total_amount_cents` before the column exists.\n\nStopping all traffic, changing the database, and starting the application again avoids overlap. It also creates downtime and turns rollback into a much sharper operation. That may be acceptable for a small internal tool. It is a poor default for a checkout that people are using while we deploy it.\n\nZero-downtime migration is not a special PostgreSQL command. It is a sequence of changes where compatibility is preserved at every step.\n\n> Change the system so old and new versions can coexist before you ask either one to disappear.\n\n## Expand before you contract\n\nThe usual pattern has two phases: expand the schema to support both versions, then contract it after the old version is gone.\n\nFor our rename, the safe path begins by adding the new column without remov
1ing the old one:\n\n```sql\nALTER TABLE orders\nADD COLUMN total_amount_cents INTEGER;\n```\n\nOld code still reads and writes `total_cents`. Nothing has broken.\n\nNext, deploy code that understands both columns. During the transition, writes keep them synchronized:\n\n```typescript\nawait database.transaction(async (transaction) => {\n await transaction.orders.update(orderId, {\n totalCents,\n totalAmountCents: totalCents,\n });\n});\n```\n\nReads prefer the new value but can fall back while old rows are being migrated:\n\n```sql\nSELECT COALESCE(total_amount_cents, total_cents) AS total_amount_cents\nFROM orders\nWHERE id = $1;\n```\n\nNow old instances can keep using the old column, and new instances can begin using the new one. The database supports both realities.\n\nOnly after every row has the new value, every writer populates it, and no old application instance remains do we remove `total_cents`.\n\nThat final removal is the contract phase. It should be the least exciting part of the change because all meaningful traffic moved away from the old column earlier.\n\n## Backfill is production work\n\nAdding an empty column is usually cheap. Filling it across a large table may not be.\n\nThis looks harmless:\n\n```sql\nUPDATE orders\nSET total_amount_cents = total_cents;\n```\n\nOn millions of rows, one giant update can hold locks, generate a large amount of transaction log, create replication lag, and compete with the queries serving users. The statement may finish successfully while making the application painfully slow.\n\nBackfill in batches instead:\n\n```sql\nUPDATE orders\nSET total_amount_cents = total_cents\nWHERE id IN (\n SELECT id\n FROM orders\n WHERE total_amount_cents IS NULL\n ORDER BY created_at\n LIMIT 1000\n);\n```\n\nRun a batch, commit it, observe the database, then continue. The exact size depends on the table, hardware, traffic, and replication setup. There is no heroic batch size that is correct everywhere.\n\nThe important shift is treating backfill as a workload, not as setup. It consumes the same database resources as customer requests. It needs progress metrics, rate control, and a way to resume after interruption.\n\n## Constraints can arrive later\n\nThe new column should eventually be `NOT NULL`. Adding that requirement immediately would reject writes from old instances that know nothing about the column.\n\nSo the order matters:\n\n1. Add the nullable column.\n2. Deploy dual-write code.\n3. Backfill existing rows.\n4. Verify that no null values remain.\n5. Add the constraint.\n\nIn PostgreSQL, validating a constraint separately can reduce the risky part of the operation on a busy table:\n\n```sql\nALTER TABLE orders\nADD CONSTRAINT orders_total_amount_present\nCHECK (total_amount_cents IS NOT NULL) NOT VALID;\n\nALTER TABLE orders\nVALIDATE CONSTRAINT orders_total_amount_present;\n```\n\nOnce validated, the schema can be tightened further according to the database version and operational needs.\n\nThe point is not memorizing this exact syntax. It is separating "start enforcing this for new changes" from "prove every old row already satisfies it." Large production changes become safer when those are not forced into one moment.\n\n## Indexes have deployment behavior too\n\nSuppose the new application also needs to find pending orders by creation time:\n\n```sql\nCREATE INDEX orders_pending_created_at_idx\nON orders (created_at)\nWHERE status = \'pending\';\n```\n\nBuilding a normal index can block writes depending on the database and operation. PostgreSQL provides `CREATE INDEX CONCURRENTLY` so the table can continue serving writes while the index is built:\n\n```sql\nCREATE INDEX CONCURRENTLY orders_pending_created_at_idx\nON orders (created_at)\nWHERE status = \'pending\';\n```\n\nThe trade-off is that concurrent creation takes longer, does more work, and cannot run inside a normal transaction block. If it fails, it may leave an invalid index that has to be removed before retrying.\n\n"Non-blocking" does not mean "free." Watch CPU, I/O, lock waits, and replication lag while it runs.\n\nThis is where migration tools can become misleading. A tool can generate valid SQL without knowing whether Tuesday afternoon is a safe time to run it against the largest table in the system.\n\n## Destructive changes deserve their own deployment\n\nDropping a column, narrowing a type, or removing an enum value is fundamentally different from adding something.\n\nAn additive change creates room. A destructive change removes a path that some code may still use.\n\nI prefer to separate destructive cleanup into a later deployment. Not five minutes later. Later enough that we can confirm:\n\n- no old instances remain;\n- no background worker uses the old shape;\n- no scheduled job wakes up with old code;
1\n- no external consumer depends on the field;\n- rollback no longer requires the old schema.\n\nThat delay can feel untidy because the database carries two columns for a while. Temporary duplication is cheaper than a clean schema that breaks a rollback.\n\n## Rollback is a compatibility test\n\nA good deployment plan asks what happens if the application must return to the previous version.\n\nWith the expanded schema, rollback is straightforward: old code still sees `total_cents`. New rows created during the deployment have both values because the transition code dual-wrote them.\n\nAfter the old column is dropped, that rollback path disappears.\n\nThis is why database rollback rarely means "run the migration file backwards." Recreating a dropped column does not restore its lost data. Converting a type back may not recover the original representation. A migration can be reversible in syntax and irreversible in meaning.\n\nFor destructive changes, forward recovery is often safer: deploy a fix that works with the current schema instead of trying to rewind production data through time.\n\n## The frontend can be part of the overlap\n\nStatic frontend assets are cached. A user may keep a tab open for hours. A service worker may serve an older bundle after the backend deployment is complete.\n\nThat means API compatibility often outlives server-instance compatibility.\n\nIf a response field changes, the expand phase may need to return both old and new shapes long enough for active clients to age out. If an enum gains a value, older clients need a fallback. If a request becomes stricter, the backend may need to accept the old shape during the transition.\n\nThe browser is not automatically upgraded because the deployment dashboard turned green.\n\nThis is one reason I prefer additive API and schema evolution. It gives independently deployed pieces room to move without pretending they change together.\n\n## A migration needs evidence, not optimism\n\nBefore contracting the schema, I want evidence that the new path owns production traffic:\n\n- null count for the new column is zero;\n- old-column reads and writes have stopped;\n- backfill progress is complete;\n- query latency and lock waits stayed acceptable;\n- replicas caught up;\n- the new application version is stable;\n- rollback expectations are explicit.\n\nSome of this comes from database queries. Some comes from logs and metrics. The important part is that removal is triggered by observed usage, not by "the deploy finished, so it should be fine."\n\nThe safest database migration is often boring and slightly slow: add, deploy, copy, observe, enforce, remove.\n\nThat is not ceremony around one line of SQL. It is how we change a shared truth while the system keeps using it.\n\nThe next article looks at a different kind of shared state. Redis is fast enough to hide many problems, which is exactly why it needs a clear job before we put it between the application and the database.\n\nIf a migration in your system still requires everyone to hold their breath during deployment, tell me which step feels dangerous. That point usually reveals the compatibility assumption we have not made explicit yet.\n'},{id:"full-stack-relational-database",title:"SQL for Frontend Engineers: Model the Relationship First",excerpt:"SQL becomes much easier when you stop thinking in tables and start thinking in facts that must remain true. A checkout model shows where keys, JOINs, transactions, and indexes actually earn their place.",tags:["Full Stack","SQL","PostgreSQL","Database","Full-Stack Field Guide"],date:"Sep 28, 2026",readTime:"9 min read",coverImage:"/blog/full-stack-relational-database/cover.png",published:!0,content:'\nA product card is one object in React.\n\nAn order summary is another object. A customer has an array of addresses. The API returns a comfortable tree, so it is tempting to imagine the database storing the same tree somewhere below the backend.\n\nA relational database does not think in component-shaped trees. It stores facts and relationships.\n\nThat difference is where SQL starts feeling either confusing or surprisingly clean. If you begin with screens, the database looks fragmented. If you begin with what must remain true, the pieces make sense.\n\nLet us model the checkout from the first two articles. Not as a complete commerce platform. Just enough to understand the decisions a frontend engineer eventually runs
1into.\n\n## Start with facts, not tables\n\nBefore writing SQL, I would write down the facts the system needs to preserve:\n\n- a customer can place many orders;\n- an order belongs to one customer;\n- an order contains one or more line items;\n- each line item records a quantity and the price charged at purchase time;\n- changing a product\'s current price must not rewrite an old order;\n- an order total cannot be negative.\n\nThat list is already most of the model. Tables are how we encode it.\n\n```sql\nCREATE TABLE customers (\n id UUID PRIMARY KEY,\n email TEXT NOT NULL UNIQUE,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n\nCREATE TABLE orders (\n id UUID PRIMARY KEY,\n customer_id UUID NOT NULL REFERENCES customers(id),\n status TEXT NOT NULL,\n total_cents INTEGER NOT NULL CHECK (total_cents >= 0),\n currency CHAR(3) NOT NULL,\n created_at TIMESTAMPTZ NOT NULL DEFAULT now()\n);\n\nCREATE TABLE order_items (\n id UUID PRIMARY KEY,\n order_id UUID NOT NULL REFERENCES orders(id),\n product_id UUID NOT NULL,\n product_name TEXT NOT NULL,\n unit_price_cents INTEGER NOT NULL CHECK (unit_price_cents >= 0),\n quantity INTEGER NOT NULL CHECK (quantity > 0)\n);\n```\n\nNothing here is clever. That is a strength.\n\nThe primary key identifies one row. The foreign key makes an invalid relationship impossible: an order cannot point to a customer that does not exist. The checks keep negative prices and zero-quantity line items out of the data, no matter which application path attempts the write.\n\nValidation in the API gives users good feedback. Constraints in the database protect the truth when every other layer makes a mistake.\n\n## Relationships are the model\n\nThe line between `customers` and `orders` is one-to-many. One customer can have many orders, while each order points back to one customer through `customer_id`.\n\nOrders and products look many-to-many: an order contains many products, and a product appears in many orders. The `order_items` table sits between them and turns that relationship into data of its own.\n\nThat middle table is not plumbing. Quantity belongs to the relationship between this order and this product. So does the price charged at that moment.\n\nThis is the first place where copying data can be correct. `product_name` and `unit_price_cents` deliberately snapshot purchase-time facts. If the catalog renames a product or changes its price tomorrow, yesterday\'s receipt must not change.\n\nNormalization does not mean "never duplicate a value." It means each fact has a clear owner. The current product price belongs to the product. The charged price belongs to the order item. Those are two different facts that happen to match at checkout time.\n\n## A JOIN rebuilds the view you need\n\nThe frontend wants an order details object. The database stores related rows. A `JOIN` reconnects them for this query:\n\n```sql\nSELECT\n orders.id,\n orders.status,\n orders.total_cents,\n orders.currency,\n customers.email,\n order_items.product_name,\n order_items.unit_price_cents,\n order_items.quantity\nFROM orders\nJOIN customers ON customers.id = orders.customer_id\nJOIN order_items ON order_items.order_id = orders.id\nWHERE orders.id = $1;\n```\n\nThe result contains one row per line item, so order fields repeat. That is normal. The backend maps those rows into the nested response the frontend wants.\n\nThis is a useful separation: storage is organized around integrity and querying; API responses are organized around the consumer\'s task. They do not need to have the same shape.\n\nTrying to make every table mirror one screen usually creates awkward data. Screens change frequently. The fact that an order belongs to a customer changes much less often.\n\n## Transactions protect multi-step changes\n\nCreating an order is not one write. We insert the order, insert its line items, and reserve inventory. If the third step fails, the first two should not remain as a convincing-looking half-order.\n\nA transaction groups the writes into one decision:\n\n```sql\nBEGIN;\n\nINSERT INTO orders (...);\nINSERT INTO order_items (...);\nUPDATE inventory\nSET available = available - $1\nWHERE product_id = $2\n AND available >= $1;\n\nCOMMIT;\n```\n\nIf any required step fails, the application rolls the transaction back. Other readers see either the state before checkout or the completed order, not the awkward middle.\n\nTransactions are not a way to make an entire distributed system atomic. A payment provider and an email service do not participate in y
1our PostgreSQL transaction. Keep the database transaction focused, commit durable state, then hand slower external work to explicit follow-up processes.\n\nThat boundary will matter when this series reaches queues and background jobs.\n\n## Concurrency is where the model gets honest\n\nSuppose only one unit remains. Two users place an order at almost the same time.\n\nIf both requests first read `available = 1` and then separately write `available = 0`, both may believe they bought it. The UI cannot prevent this. Disabling a button only affects one browser.\n\nThe conditional update above makes the database perform the check and write together. The backend then verifies that exactly one row changed. One order proceeds; the other receives an out-of-stock result.\n\nThis is why I think of the database as more than persistence. It is the final place where concurrent claims about shared state meet.\n\n> If two requests can disagree, the invariant belongs where both requests must pass.\n\nFrontend optimistic updates still have value. They make the interface responsive. They simply do not get the final vote on shared inventory, balances, permissions, or uniqueness.\n\n## Indexes answer a specific question faster\n\nAn index is often explained as "something that makes queries fast." True, but incomplete. An index makes a particular access pattern faster, while adding storage and write cost.\n\nThis query will become common:\n\n```sql\nSELECT id, status, total_cents, currency, created_at\nFROM orders\nWHERE customer_id = $1\nORDER BY created_at DESC\nLIMIT 20;\n```\n\nA matching index is:\n\n```sql\nCREATE INDEX orders_customer_created_at_idx\nON orders (customer_id, created_at DESC);\n```\n\nThe column order reflects the query: first narrow rows to one customer, then read them in the requested time order.\n\nAdding indexes to every column is not a strategy. Each index must be updated when data changes. The right question is not "which fields look important?" It is "which query is slow, how does it filter, and how does it sort?"\n\nUse the database\'s query plan instead of guessing. In PostgreSQL, `EXPLAIN ANALYZE` shows what actually ran, how many rows were examined, and where time went. It turns performance from folklore into evidence.\n\n## The N+1 problem starts as innocent code\n\nImagine loading twenty orders, then loading items for each order in a loop:\n\n```typescript\nconst orders = await orderRepository.findByCustomer(customerId);\n\nfor (const order of orders) {\n order.items = await orderItemRepository.findByOrder(order.id);\n}\n```\n\nOne query loads the orders. Twenty more load their items. That is N+1: one initial query plus one query per result.\n\nIt can hide behind a clean ORM relationship and look harmless in development with three records. Under real latency and larger result sets, it becomes a page that gets slower as the product succeeds.\n\nThe fix may be a join, a batched `WHERE order_id IN (...)` query, or an ORM include that genuinely batches. The important part is knowing how many database round trips the convenient abstraction produces.\n\nAn ORM can save enormous amounts of repetitive work. It cannot make the cost model disappear.\n\n## NULL is a domain decision\n\nNullable columns deserve more thought than they usually get.\n\nDoes `shipped_at = NULL` mean the order has not shipped, shipping does not apply, or old data was never migrated? Those are different states hiding behind the same absence.\n\nSometimes null is exactly correct. A pending order genuinely has no shipment timestamp yet. But if absence carries several meanings, make the state explicit with a status or separate relation rather than forcing every query and component to guess.\n\nThis maps directly to TypeScript. A nullable database field becomes `string | null` somewhere in the application. If frontend code keeps asking "why can this be null?", the uncertainty may begin in the data model, not the component.\n\n## Learn enough SQL to see through the abstraction\n\nTyped query builders and ORMs are useful. I would rather write most application queries through one than concatenate SQL strings by hand.\n\nBut the abstraction should make SQL easier to produce, not unnecessary to understand. You still need to see the relationships, transaction boundary, indexes, constraints, and number of round trips. Otherwise a pleasant method chain can hide an expensive or incorrect operation.\n\nThe frontend parallel is familiar. A component library saves us from rebuilding every button, but we still need to understand HTML and accessibility. The database deserves the same respect.\n\nOnce the model is clear, SQL stops looking like backend punctuation. It becomes a precise way to ask questions about facts you have deliberately structured.\n\nThe next problem is changing that structure while old application instances are still running. That is
1where a harmless-looking rename becomes a production migration.\n\nIf there is a database concept you have been treating as "backend only," tell me which one. The best topics in this guide are exactly the boundaries frontend engineers are expected to cross without anyone first drawing the map.\n'},{id:"full-stack-api-contracts",title:"An API Is a Boundary, Not a URL",excerpt:"A working endpoint is not yet a reliable API. The real contract includes validation, errors, retries, idempotency, and a way to evolve without making yesterdayâs frontend fail tomorrow.",tags:["Full Stack","API Design","TypeScript","Full-Stack Field Guide"],date:"Sep 26, 2026",readTime:"9 min read",coverImage:"/blog/full-stack-api-contracts/cover.png",published:!0,content:"\nThe most dangerous API response is not always a `500`.\n\nSometimes it is a `200` containing almost the data your frontend expected.\n\nThe request worked. JSON arrived. The loading state disappeared. Then a component tried to format `order.total.amount`, except the backend now returns `totalAmount`. Nothing failed at the network boundary, so the failure moved deeper into the UI where it became harder to explain.\n\nThat is what makes API work deceptive. We tend to describe an API as a list of URLs, but the URL is the least interesting part. The real API is an agreement about inputs, outputs, errors, timing, repetition, and change.\n\n> An endpoint tells you where to send a request. A contract tells both sides what the request means.\n\nLet us keep following the checkout request from the previous article and make that contract explicit.\n\n## Validate where trust changes\n\nThe browser sends this body:\n\n```json\n{\n \"cartId\": \"cart_42\",\n \"deliveryMethod\": \"courier\"\n}\n```\n\nTypeScript can prove that our frontend created the right object. It cannot prove that the server received it unchanged. Requests can come from an old frontend bundle, a mobile client, a script, a browser extension, or someone calling the endpoint by hand.\n\nThe network boundary erases TypeScript's guarantees. Runtime validation restores them.\n\n```typescript\nimport { z } from 'zod';\n\nconst CreateOrderRequest = z.object({\n cartId: z.string().min(1),\n deliveryMethod: z.enum(['courier', 'pickup']),\n});\n\nconst input = CreateOrderRequest.parse(request.body);\n```\n\nThis is not defensive programming for unusually malicious users. It is how the backend prevents an assumption from travelling further into the system.\n\nWithout validation, an invalid `deliveryMethod` might reach pricing, inventory, and persistence before something finally throws. With validation, the request stops at the boundary and returns a useful problem while the system is still in a known state.\n\nThe response needs the same treatment on the frontend. A generated type or shared interface improves development, but it is not runtime evidence.\n\n```typescript\nconst OrderResponse = z.object({\n id: z.string(),\n status: z.enum(['pending', 'confirmed']),\n total: z.object({\n amount: z.number(),\n currency: z.string().length(3),\n }),\n});\n\nconst order = OrderResponse.parse(await response.json());\n```\n\nI do not validate every harmless response in every application. That can become ceremony. I validate the boundaries where wrong data would be expensive, confusing, or difficult to recover from: authentication, checkout, payments, persisted user work, and integrations owned by another team.\n\n## Errors are part of the response model\n\nA successful response usually gets careful types. Errors often get whatever the framework happens to throw.\n\nThat creates frontend code like this:\n\n```typescript\ntry {\n await createOrder(input);\n} catch (error) {\n showToast('Something went wrong');\n}\n```\n\nThe message may be acceptable for an unknown infrastructure failure. It is terrible for \"one product is no longer available\" or \"the delivery address is outside the service area.\" Those are expected business outcomes. The user can act on them.\n\nA small structured error is enough:\n\n```typescript\ntype ApiProblem = {\n code: 'CART_CHANGED' | 'OUT_OF_STOCK' | 'INVALID_ADDRESS';\n message: string;\n field?: string;\n requestId: string;\n};\n```\n\nThe stable part is `code`. The backend can improve `message` without breaking frontend behavior. The optional `field` lets a form place the error next to an input. The `requestId` connects the user's failure to server logs.\
1n\nHTTP status still matters. A `400` means the request itself is invalid. `401` means authentication is missing or no longer valid. `403` means identity is known but the action is forbidden. `409` means the request conflicts with current state. `429` means slow down. `500` means the server failed to complete an operation it should have been able to complete.\n\nThe status gives infrastructure and generic clients a category. The problem code gives the product a decision.\n\n## Retrying changes the meaning of a request\n\nOur user presses **Place order**. The backend creates the order, but the connection drops before the response reaches the browser.\n\nWhat should the UI do?\n\nFrom its point of view, the result is unknown. Retrying might recover cleanly. It might also create a second order.\n\nThis is where idempotency stops being backend vocabulary and becomes a user-experience requirement. The frontend generates a key for the action and sends it with the request:\n\n```typescript\nconst idempotencyKey = crypto.randomUUID();\n\nawait fetch('/orders', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Idempotency-Key': idempotencyKey,\n },\n body: JSON.stringify(input),\n});\n```\n\nThe backend stores the key with the result. If the same operation arrives again, it returns the original result instead of performing the write twice.\n\nThe detail people miss is scope. The key belongs to one user intent, not one HTTP attempt. A network retry reuses it. A genuinely new click after the user changes the cart gets a new key.\n\nDisabling the button is still good interface behavior. It is not a correctness guarantee. Two tabs can submit. A client can retry. A timeout can hide a successful response. Correctness must survive outside the DOM.\n\n## Versioning is mostly about restraint\n\nTeams often imagine API versioning as `/v1`, `/v2`, then a heroic migration. Most healthy evolution is much less dramatic.\n\nAdding an optional field is usually safe. Adding a new enum value may not be safe if the frontend uses an exhaustive switch and has no fallback. Renaming a field is breaking. Changing `null` to an omitted property can be breaking. Turning cents into decimal currency units while keeping the field name is definitely breaking, and especially unpleasant because the value still looks valid.\n\nMy default rule is additive evolution:\n\n- add before removing;\n- let old and new clients coexist;\n- measure whether the old field is still used;\n- remove only after the migration is complete.\n\nFor a rename, the backend can temporarily return both fields:\n\n```json\n{\n \"totalAmountCents\": 4299,\n \"total\": {\n \"amount\": 42.99,\n \"currency\": \"CAD\"\n }\n}\n```\n\nThe frontend moves to `total`. Telemetry confirms old consumers have disappeared. Only then does `totalAmountCents` go away.\n\nThat overlap can feel untidy. It is also much cheaper than requiring every deployed client to change at the same instant.\n\n## Dates and money expose weak contracts quickly\n\nTwo values deserve suspicion at every API boundary: dates and money.\n\n`42.99` is not a complete monetary value. Which currency? Is it a floating-point amount or a display representation? Can tax introduce more precision? A safer contract sends an integer in minor units or a decimal string, plus an explicit currency.\n\nLikewise, `2026-09-26` is a calendar date, while `2026-09-26T14:30:00Z` is an instant. They are not interchangeable. A delivery day may intentionally have no timezone. An order creation timestamp absolutely does.\n\nThe frontend should not have to infer these semantics from a field name. Good contracts make units and meaning boringly explicit.\n\n## Generate types, but keep a runtime boundary\n\nOpenAPI, GraphQL code generation, tRPC, and shared schema packages can remove a large amount of duplicated typing. I like that. If the server already knows the response shape, the frontend should not retype it from memory.\n\nBut generated types answer a build-time question: do these codebases agree on the declared contract?\n\nRuntime val
1idation answers a different question: did this particular response satisfy it?\n\nYou may need both. A generated client keeps everyday development fast. Runtime parsing protects the important places where deployment skew, stale clients, proxies, or external integrations can make reality differ from the declaration.\n\nThe useful architecture is not \"validate everything twice.\" It is to choose where a contract deserves enforcement and make that choice visible.\n\n## The UI should consume decisions, not transport details\n\nA component should not contain a switch over raw status codes, parse arbitrary error bodies, or know that one endpoint wraps data under `result` while another does not.\n\nPut that translation in the API layer:\n\n```typescript\ntype CreateOrderResult =\n | { ok: true; order: Order }\n | { ok: false; reason: 'cart-changed' | 'out-of-stock' | 'invalid-address' };\n```\n\nNow the UI handles product outcomes. The API client handles HTTP, schemas, and server problem codes. The backend handles validation and business rules. The boundaries line up with the questions each layer can answer.\n\nThat is the part I care about most. Type safety is useful, but the real goal is not a beautiful generated interface. It is making failure unsurprising.\n\nThe next article goes one layer deeper. Once the API accepts \"create order,\" the database has to preserve customers, orders, products, and their relationships without letting them contradict each other.\n\nIf your frontend has to guess what an API error means, send me the shape of that guess. Those awkward branches are usually the clearest map of where the contract needs work.\n"},{id:"full-stack-after-fetch",title:"What Actually Happens After fetch()",excerpt:"A frontend request does not jump straight from the browser into your backend. It crosses DNS, encrypted connections, edge infrastructure, application code, and a database before the UI sees a result.",tags:["Full Stack","Web Architecture","Networking","Full-Stack Field Guide"],date:"Sep 24, 2026",readTime:"8 min read",coverImage:"/blog/full-stack-after-fetch/cover.png",published:!0,content:"\nA user clicks **Place order**. Your component calls `fetch('/orders')`. A moment later, it either receives JSON or lands in `catch`.\n\nThat can feel like one operation. It is not even close.\n\nBetween the click and the response, the request may cross a DNS resolver, an encrypted connection, a CDN, a load balancer, an application process, a cache, and a database. Then the response has to travel back through most of that chain. Any link can be slow, unavailable, misconfigured, or perfectly healthy while the next one is failing.\n\nFrontend engineers do not need to become network engineers to work with this well. But we do need a useful picture of what happens after `fetch()`. Without it, every failure becomes \"the API is broken,\" and that sentence is usually too vague to help anyone.\n\nSo let me follow one request all the way through.\n\n## Start with the URL\n\nImagine the browser is about to send this:\n\n```typescript\nawait fetch('https://api.shop.example/orders', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ cartId: 'cart_42' }),\n});\n```\n\nBefore any application code runs, the browser has several questions to answer:\n\n- Where is `api.shop.example`?\n- Can I establish a secure connection to it?\n- Which machine should receive this request?\n- Is there already a connection I can reuse?\n\nThe URL is not an address a network understands directly. It is a human-readable instruction that has to be resolved into infrastructure.\n\n## DNS finds the entrance\n\nDNS turns `api.shop.example` into an IP address, or more often into another hostname that eventually leads to one.\n\nThe browser first checks caches: its own, the operating system's, perhaps the local network's. If there is no usable answer, a DNS resolver looks up the records. The final result may point directly to a server, but production systems often point to a CDN or load balancer instead.\n\nThis is the first useful debugging boundary. If DNS is wrong, your backend can be completely healthy and still receive nothing. The browser may report `DNS_PROBE_FINISHED_NXDOMAIN`, while an application dashboard shows zero errors because the request never reached the application.\n\nThat distinction matters. \"No server logs\" does not always mean \"the browser did not send it.\" It may mean the browser could not find the server.\n\n## The browser creates a secure connection\n\nOnce the browser has an address, it needs a connection. With HTTP/1.1 or HTTP/2, that normally means TCP. With HTTP/3, it uses QUIC over UDP. The transport details differ, but the practical goal is the same: create a reliable path for exch
1anging data.\n\nBecause the URL uses HTTPS, TLS also has to establish trust and encryption. The server presents a certificate for the domain. The browser checks that the certificate is valid, trusted, not expired, and actually covers `api.shop.example`.\n\nIf that fails, React never gets an HTTP status. There is no `502` response to inspect. The secure conversation did not begin.\n\nThis is why \"network error\" is such an unhelpful bucket in frontend code. It can mean offline Wi-Fi, DNS failure, TLS failure, a blocked connection, a proxy problem, or a request cancelled by our own code. Those failures happen before an API can return a neat JSON error.\n\n## The first machine may not be your backend\n\nSuppose DNS and TLS work. The request reaches the public edge of the system.\n\nThat edge might be a CDN such as CloudFront or Cloudflare. Static files can be returned there without touching the application. Dynamic requests usually continue inward, but the edge can still enforce rate limits, reject suspicious traffic, terminate TLS, or attach routing headers.\n\nNext may come a reverse proxy or load balancer. Its job is not to understand the shopping cart. It decides which healthy application instance should receive the request.\n\n```text\nbrowser\n -> DNS\n -> secure connection\n -> CDN / edge\n -> load balancer\n -> application instance\n```\n\nThis is where one public API becomes many private processes. The user sees `api.shop.example`. Behind it, five application instances may be serving traffic. A health check tells the load balancer which instances are ready. If one process crashes, new requests go elsewhere.\n\nThat sounds like an infrastructure detail until application state lives only in the memory of one instance. Then the first request creates a checkout session on server A, the second lands on server B, and the session appears to have vanished. Horizontal scaling has exposed an application design problem.\n\nWe will return to that later in this series. For now, the important point is simple: a domain name does not identify one process.\n\n## Now the application gets a turn\n\nOnly after all of that does the request reach the route handler we usually call \"the backend.\"\n\nThe application parses the body, authenticates the user, checks authorization, validates the input, and decides what operation \"create order\" means. It may call another service. It may read Redis. It may open a database transaction. It may publish a job to a queue.\n\nFor our order, the simplified path could be:\n\n```text\nvalidate request\n -> load cart\n -> confirm current prices\n -> reserve inventory\n -> create order\n -> return order id\n```\n\nEach arrow is another boundary and another possible failure.\n\nThis is why a backend can be \"up\" while a request still fails. The process responds to health checks, but the database connection pool is exhausted. Or Redis is unavailable. Or the inventory service times out. A green server is not the same thing as a successful operation.\n\n## The database is not just storage at the end\n\nThe database often decides whether the whole operation is trustworthy.\n\nCreating an order may require several writes that must succeed together: insert the order, reserve stock, and record the payment attempt. If two writes succeed and the third fails, we do not want half an order. A transaction gives the backend a boundary: commit the whole change, or roll it back.\n\nThe query also has a cost. Missing indexes can turn a small lookup into a scan across millions of rows. A lock held by another transaction can make a fast query wait. Too many concurrent requests can exhaust the connection pool even when the database itself still has CPU available.\n\nFrom the browser, all three may look like a slow spinner.\n\nThat is the recurring theme of this journey: the UI sees the total time, not the layer that spent it.\n\n## The response walks back through the system\n\nWhen the application finishes, it returns an HTTP response. That response passes back through the load balancer and edge, crosses the encrypted connection, and reaches the browser.\n\nThen `fetch()` does something that still catches people: it resolves for HTTP errors. A `400` or `500` is a successful network exchange, so the promise resolves. We have to inspect `response.ok` ourselves.\n\n```typescript\nconst response = await fetch('/orders', options);\n\nif (!response.ok) {\n const problem = await response.json();\n throw new OrderRequestError(response.status, problem);\n}\n\nreturn response.json();\n```\n\nThe promise rejects when the browser cannot complete the exchange at all, or when our code aborts it. Those are different categories from a backend that deliberately returned a validation error.\n\nA useful frontend error model keeps that distinction:\n\n- **No response:** connection, DNS, TLS, offline state, timeout, or cancellation.\n- **HTTP response with a problem:** authentication, validation, conflict, rate limit, or server failure.\n- **Successful response with unexpected data:** contract or parsing failure.\n\nOne generic \"Something went wrong\" may be the right user-facing sentence. It should not be the only thing the system knows.\n\n## A request id connects the pieces\n\nThere is one small habit that makes this entire chain easier to reason about: give every request a correlation id.\n\nThe edge can create it, or the frontend can send one. Each service includes it in logs, and the response returns it as a header. Now a bug report can connect one browser event to the exact load-balancer entry, application log, and database operation.\n\n```typescript\nconst requestId = crypto.randomUUID();\n\nawait fetch('/orders', {\n ...options,\n headers: {\n ...options.headers,\n 'X-Request-Id': requestId,\n },\n});\n```\n\nThat id is not observability by itself. But it gives the layers a shared thread, which is much better than comparing timestamps and hoping two errors belong to the same click.\n\n## What this changes in frontend code\n\nUnderstanding the path does not mean the component should know about DNS or load balancers. Quite the opposite. It helps us create a cleaner boundary around network work.\n\nA UI should know whether an action is pending, whether it can be retried, and what the user can do next. The API client should distinguish no-response failures from HTTP problems and invalid payloads. The backend should return structured errors. Infrastru
1cture should expose enough context to trace the request across layers.\n\nEach layer owns a different question.\n\n> The browser sees one request. The system sees a chain of contracts.\n\nOnce that picture is clear, several full-stack topics stop feeling unrelated. Idempotency protects a repeated request. Transactions protect a multi-step write. Redis changes where data is read. A load balancer changes where code runs. Observability connects the path after something fails.\n\nThey are all answers to the same question: what must stay true while a request crosses the system?\n\nThe next article follows the request into the application and focuses on the first contract we control directly: the API boundary.\n\nIf there is a part of the request path that still feels like a black box, tell me where you lose the thread. That is probably the part of this guide worth opening up next.\n"},{id:"github-ai-pr-reviewer",title:"AI Code Review Should Sound Like a Careful Engineer",excerpt:"I built a personal AI reviewer for GitHub Pull Requests, not as a cold rule bot, but as a local and observable tool that checks known risk patterns and turns them into useful engineering conversation.",tags:["AI Agents","Code Review","GitHub","Engineering Workflow"],date:"Sep 22, 2026",readTime:"8 min read",coverImage:"/blog/github-ai-pr-reviewer/cover.png",published:!0,content:'\nThere is a strange cost to a queue of Pull Requests: it rarely looks like a problem.\n\nOne PR can be reviewed in the evening. The second between meetings. The third looks small, just dependencies. The fourth already passed CI, so it probably feels fine. Very quickly, review stops being focused engineering work and becomes a memory task: where exactly am I expected to pay attention?\n\nThe routine itself was not what bothered me. Review is part of the job. What bothered me was that the most repeatable risks require the most boring kind of attention. Did an auth guard disappear? Did unsafe `innerHTML` show up? Did a dependency update add an install lifecycle script? Was an export removed? Did an API, schema, or config contract change in a way the author may not have noticed?\n\nThese are not the places where you need a genius reviewer. They are the places where it is unpleasantly easy to miss something.\n\nSo I built a personal AI reviewer for GitHub Pull Requests. Not because I wanted to replace human review. And definitely not because I wanted another bot leaving comments like "Potential security issue." I wanted a tool that keeps a baseline level of risk attention running consistently, while still speaking to the author like a reasonable engineer.\n\n## I did not want a rule bot\n\nThe simplest version of this tool is obvious: scan the diff for patterns, leave comments.\n\nThat becomes bad review very quickly.\n\nThe machine sees a fragment. It does not know the whole product history, the migration path, the team\'s local agreement, the temporary rollout plan, or why the author made that trade-off. If it writes with too much certainty, it starts sounding smarter than it has earned the right to be.\n\nAt the same time, I did not want a harmless toy that is afraid to say something real. Good review does not have to be soft. If a PR removes a permission guard or adds process execution from user-controlled input, the reviewer should stop and ask for changes. But even then, the job of the comment is to move the author toward verification and a decision, not to drop a red flag and leave.\n\nThe principle became:\n\n> Automated review should be specific, checkable, and modest about what it knows.\n\nThat modesty is not politeness theater. It reflects the reality of review. The reviewer often sees the diff, but not the whole context.\n\n## The local reviewer\n\nThe tool lives locally, outside cloned repositories. That was the first practical decision. I did not want a normal repository cleanup, reinstall, or update to accidentally delete the system that watches my review queue.\n\nIt runs through macOS `launchd`: it starts after login and then runs a review cycle roughly once an hour. Each cycle finds GitHub Pull Requests where my account is listed as a requested reviewer.\n\nThis is not a global GitHub scanner. It is my personal review inbox. If someone explicitly asks me to review a PR, the tool can put it into
1its queue.\n\nBefore reviewing, it waits until the checks for the current head commit are green. That constraint matters. If CI is already failing, an automated reviewer tends to comment on noise: formatting, types, broken tests, obvious things the author needs to fix before a real review. I want the tool to review something close to a human-reviewable state, not compete with CI.\n\nIt also stores local state: which PRs it has already reviewed and which head SHA it saw at the time. If new commits are pushed to a previously reviewed PR, the agent reviews it again, even if the requested-reviewer event did not happen again.\n\nThat small detail changes the behavior. A PR is not a static document. The author may respond to feedback, rewrite part of the logic, update dependencies, or fix tests. A reviewer that only remembers "I have seen this PR" becomes blind to the most important part of the process: what changed after the first round.\n\n## What it does, and what it does not do\n\nThe main path is simple: the agent reads the diff and publishes comments on the relevant diff lines. If GitHub does not accept an inline comment â for example, because the diff position is no longer valid or the API cannot attach the note to that line â it falls back to a body-only review and preserves the meaning of the finding.\n\nThat matters more than it sounds. Bad automation often breaks on a small API detail and silently drops the useful signal. I would rather leave a body review than pretend there was nothing to say.\n\nThe checks are intentionally simple and explainable: dynamic code execution, unsafe `innerHTML`, process execution, install lifecycle scripts, removed exports, risky API/schema/config changes, and disappearing auth or permission guards.\n\nThis is not a complete model of code quality. It is not a senior engineer replacement. It is a first, repeatable pass over known classes of risk.\n\nThere is also a boundary around approval. The agent does not approve PRs with unresolved review threads; it leaves a comment instead. It can auto-approve safe dependency-bot PRs, but normal PRs are limited by the number of reviews per run.\n\nI want automation to help, not turn into an uncontrolled conveyor belt of approvals.\n\n## The most important iteration was the comment style\n\nTechnically, building the loop was not the most interesting part. Finding PRs, checking requested reviewers, waiting for checks, reading diffs, and leaving reviews are all annoying in the details, but conceptually straightforward.\n\nThe most important iteration was the language of the comments.\n\nEarly automated review text tends to collapse into dry warnings:\n\n"Potential security issue."\n\n"Validate compatibility."\n\n"This may break consumers."\n\nThose comments can be technically correct and still be bad review. They do not tell the author what to check. They do not connect the concern to the specific change. They sound like a confident alarm with missing context.\n\nSo I rewrote the rules around how comments should be phrased. A comment should name the specific risk, attach it to the change, explain why the diff raised concern, and ask a question the author can answer.\n\nFor example, instead of "Validate compatibility," a better comment is:\n\n> Can you confirm whether this exported type is still used by downstream packages? I could be missing the migration path, but removing it here looks like it may break consumers outside this repo.\n\nThe value is not that the sentence became softer. The value is that it gives the author a concrete check: downstream usage and migration path.\n\nAnother example:\n\n> Can we make sure this HTML is sanitized before it reaches `dangerouslySetInnerHTML`? The diff makes the rendered value look closer to user-controlled content than before.\n\nThat does not scream "security bug." But the risk is direct: sanitization and user-controlled content.\n\nOne more:\n\n> Can you confirm this route still requires the same permission check? I see the guard moved out of this branch, and I want to make sure the access rule did not become implicit.\n\nThis is the tone I want. The reviewer does not say, "you removed auth." It says: I see a change, it looks like a risk, help confirm the invariant.\n\nThat is the difference between humanizing text and pretending to be human. The goal is not to disguise AI as a teammate. The goal is to automate the best parts of good engineering review: specificity, respect for context, an argument behind the concern, and the willingness to be wrong.\n\n## Why the control panel matters\n\nAutomation that runs somewhere in the background can become a source of anxiety very quickly.\n\nIs it checking anything? Did it crash? Is it writing a comment right now? Did it skip a PR because checks were red, or because it did not see the requested reviewer? Has it already reviewed this SHA? Did a new commit appear after the last run?\n\nIf those questions are hard to answer, the tool saves time in one place and spends trust in another.\n\nSo the reviewer has a local browser control panel, available only on localhost. It shows whether the service is running, the `launchd` state, and the current process state. It has Start, Stop, Restart, Refresh, and logs.\n\nThe panel shows the timer until the next review cycle, the last run time, and whether a cycle is currently active. It has an event feed: PRs found, skips, errors, published reviews, and links to the PR or review.\n\nIt also shows counters and a tracked PR table: total tracked PRs, how many are visible, how many need rereview, which SHA was reviewed, and which SHA appeared later.\n\nThe logs are not hidden. I can open the service output and see what actually happened.\n\nThat is not decoration. Observability is part of the product. If automation leaves comments in your name, you should not need a terminal investigation to understand what it did.\n\n## The boundaries I want to keep\n\nI do not want a reviewer that pretends to understand everything.\n\nIt does not know the product better than the team. It should not approve normal PRs just because it did not find a risk pattern. It should not argue with the author instead of a person. It should not turn a simple dependency update into a philosophical review if the known safe conditions are satisfied.\n\nIts value is narrower: it looks consistently at classes of risk that are easy to miss, waits for a reasonable check state, remembers reviewed SHAs, returns after new commits, and makes its comments visible inside the conversation humans are already having.\n\n
1Most importantly, it does not have to be perfect to be useful. But it does have to be inspectable. I need to see what it did, why it skipped a PR, which SHA it reviewed, where it posted the review, and when the inline-comment fallback became a body-only review.\n\nMature automation should not feel like magic. Magic is hard to debug.\n\n## What I built, really\n\nI did not start with the idea of replacing review. I started with the feeling that some attention could become continuous.\n\nA Pull Request is still a conversation between engineers. The author knows the context of the change. The reviewer sees risk from the outside. CI checks formal properties. An agent can add another layer: a repeatable pass over known dangerous areas, with comments that help the author respond in a useful way.\n\nThat is the shape I like. Not a cold judge. Not autopilot approvals. Not a bot that writes "Potential issue" and disappears.\n\nMore like a careful junior reviewer: arrives on time, remembers what it saw, asks specific questions, and leaves the decision to people.\n\nThe value is not that AI replaced a reviewer. The value is that attention to risk became regular, observable, and part of the process.\n\nThat is the kind of automation I am interested in: limited, checkable, useful to humans, and modest enough not to get in the way of a normal engineering conversation.\n\nIf you\'re building an AI reviewer of your own, I\'d be curious to hear where you draw the line between useful vigilance and automated noise. Reach out and tell me what earned your trust, and what lost it.\n'},{id:"jev-human-text-to-data",title:"The Columns Hidden Inside Your Customer Reviews",excerpt:"What interests me about TypeSafe's Jev is turning human language into data we can query. Restaurant reviews, product complaints, and Reddit discussions make the idea much more concrete than another chatbot benchmark.",tags:["AI","Data Engineering","Product Engineering"],date:"Sep 17, 2026",readTime:"9 min read",coverImage:"/blog/jev-human-text-to-data/cover.png",published:!0,content:"\n# The Columns Hidden Inside Your Customer Reviews\n\nA restaurant has ten thousand reviews. Its dashboard has an average rating, a monthly trend, and a search box.\n\nSomewhere inside those reviews is a much more useful answer: people like the food, but delivery packaging keeps ruining it.\n\nThere is no column for that.\n\nThat is what interests me about TypeSafe's Jev. I want to look at it as a way to turn human text into data: reviews, comments, forum discussions, the messy sentences that contain signals our databases cannot currently query.\n\nI recently wrote about a [restaurant procurement agent](/blog/silpo-ai-factory-horeca-procurement-agent). That project started with operational inputs: menus, portions, inventory, supplier availability. Here I want to look at another side of the same business. Customers are already describing problems. How do those descriptions become something an engineer can aggregate and a restaurant owner can investigate?\n\nThis is an architecture I would explore, not a report from a deployed Jev integration. The examples below are hypothetical. I haven't run a benchmark on restaurant reviews.\n\n## A prep station for language\n\nThink about a restaurant kitchen before service. Ingredients arrive in different shapes. Someone washes, separates, portions, and puts them into containers the rest of the kitchen can work with.\n\nI see a similar job between raw text and analytics.\n\nA review arrives as a paragraph. The next stage produces a few useful attributes. Conventional software groups and counts them. A person, or a more capable language model with access to the evidence, can then work on the interpretation.\n\nThe distinction matters because [Jev gives up free-form string generation](https://typesafe.ai/blog/introducing-system-one-models-and-jev). TypeSafe presents it as a model for fast, structured decisions. It doesn't browse Reddit for you or write the research report. You supply the material and the questions.\n\nIts [three question types](https://docs.typesafe.ai/primitives) are Noul, a yes/no probability; Choice, a selection from supplied alternatives with probabilities; and Score, a position along defined levels. That is enough to add many useful attributes to an existing record. It doesn't make it an arbitrary extractor of new names, quotations, or product identifiers.\n\nIf I need the exact restaurant name, I would preferably get it from source metadata. If I need an exact quotation, I would preserve the original text. Asking a classifier to choose between known categories is a different operation from asking a model to discover and return an unrestricted string.\n\n> The interesting AI feature is the column you couldn't populate before.\n\n## One review can contain several different problems\n\nConsider this invented delivery review:\n\n*âThe noodles were great, but the sauce leaked through the bag. This is the second time. I'd order again if they changed the containers.â*\n\nA single positive/negative label throws away most of what makes that useful.\n\nI would ask whether the author praises the food, reports a packaging failure, describes a repeated problem, and makes a future order conditional on a change. Those signals can coexist. Forcing them into one winning category would lose information before the analysis even starts.\n\nAt the application boundary, a normalized record might look like this. The values are illustrative, and this is my storage shape, not a Jev API response:\n\n```typescript\ntype ReviewSignals = {\n reviewId: string;\n restaurantId: string;\n source: 'delivery' | 'direct' | 'forum';\n publishedAt: string;\n foodPraiseProbability: number;\n packagingFailureProbability: number;\n repeatProblemProbability: number;\n conditionalReturnProbability: number;\n modelVersion: string;\n questionSetVersion: string;\n};\n```\n\nThe first four fields come from ingestion. The next four are model judgments. The last two let me explain how those judgments were produced.\n\nI would keep the raw review linked to this row. An owner clicking âpackaging complaints increasedâ should be able to read the evidence, including the examples the classifier got wrong.\n\nQuestion wording deserves as much care as the schema. âIs packaging bad?â invites a broad interpretation. âDoes the author report that the container or bag leaked, broke, or failed to contain the food?â defines an observable claim.\n\nEven then, a complaint describes the author's account. It doesn't establish which party caused the failure. A crushed meal might involve the container, the courier, or both. I would resist naming the field `restaurant_packaging_defect` unless the evidence actually supports that attribution.\n\n## Parallel questions, then parallel records\n\nThere are two kinds of parallel work here, and I would keep them separate in the design.\n\nFor one review, ask all the independent questions together. TypeSafe documents that questions in a request share the same input state and are evaluated independently. One answer does not become context for the next. If a later question requires information fetched using an earlier answer, that needs another step. [Question semantics](
1https://docs.typesafe.ai/primitives).\n\nAcross the dataset, workers process different reviews concurrently. That is our ingestion and scheduling problem: queues, bounded concurrency, retries, rate limits, and resumable jobs. Parallel question evaluation doesn't promise unlimited corpus throughput.\n\nThe pipeline I have in mind is quite ordinary:\n\n```text\nSource exports / permitted APIs\n â\nNormalize records, retain context, remove duplicates\n â\nQueue â workers â Jev questions for each record\n â\nVersioned semantic attributes + original record links\n â\nSQL aggregates â evidence review â optional written report\n```\n\nI would give each result a key built from the source record, content hash, model version, and question-set version. Retrying a request should not create a second observation in the dashboard. Changing a question should not silently overwrite the previous measurement.\n\nThe kitchen analogy has a useful limit here. You can prepare ingredients in parallel, but putting twice as many cooks in one doorway won't double dinner service. The slowest part might be collecting the data, resolving duplicates, or reviewing ambiguous examples.\n\n## Let SQL do the counting\n\nOnce those columns exist, familiar tools become useful again.\n\nHere is an illustrative PostgreSQL query over enriched delivery reviews. The 0.8 cutoff is a placeholder to validate against labeled examples, not a recommended universal threshold:\n\n```sql\nSELECT\n restaurant_id,\n date_trunc('month', published_at) AS month,\n count(*) AS analyzed_reviews,\n count(*) FILTER (\n WHERE packaging_failure_probability >= 0.8\n ) AS flagged_packaging_reviews,\n round(\n 100.0 * count(*) FILTER (\n WHERE packaging_failure_probability >= 0.8\n ) / count(*),\n 1\n ) AS flagged_share_pct\nFROM review_signals\nWHERE source = 'delivery'\n AND model_version = :model_version\n AND question_set_version = :question_set_version\nGROUP BY restaurant_id, date_trunc('month', published_at);\n```\n\nThat last number is the share of analyzed reviews flagged by this rule. It is not the percentage of orders with failed packaging. We don't have all orders in this table. We have people who wrote reviews, within whatever collection process we used.\n\nI'd put that distinction in the dashboard label. Otherwise a technically correct query becomes a misleading product feature.\n\nThe next useful question might be whether flagged reviews concentrate around one location, a delivery channel, or a period after a container change. Those joins require reliable operational metadata. Jev cannot supply missing order history by interpreting a paragraph more confidently.\n\n## The same idea works for products\n\nNow replace the restaurant with a coffee grinder.\n\nâLoudâ can mean several things. One owner mentions the noise and still recommends it. Another says it wakes their child and explains why they returned it. A third repeats something they heard without owning the product.\n\nI would want separate signals for claimed ownership, noise complaints, reported returns, and explicit reasons for returning. These are much more useful than a single sentiment score when deciding what to investigate about a product.\n\nThe model still only judges what the text supports. âThe author claims to own itâ is a defensible label. âVerified purchaserâ requires purchase evidence outside the text.\n\nThere is also a discovery problem. A fixed question set finds the issues I thought to ask about. If a new failure mode appears and none of my questions cover it, a cheap classifier can miss it at enormous scale.\n\nI would regularly inspect a diverse sample of unflagged and uncertain records, and use open-ended analysis to propose new categories. Then I would label examples, evaluate the revised questions, and backfill comparable periods. Otherwise the dashboard can look stable simply because its vocabulary stopped evolving.\n\n## Reddit is a research corpus, with a boundary\n\nThe Reddit version interests me even more: a deliberately scoped collection of posts and comments about a product or buying decision.\n\nSuppose I want to investigate why people discussing home espresso equipment regret a purchase. I would define the communities, time window, collection method, and inclusion criteria first. Then I could classify first-hand reports, price objections, maintenance complaints, and explicit alternatives people are considering.\n\nA comment like âsame hereâ needs its parent. A sarcastic reply can reverse the apparent meaning. I would pass the relevant context with the comment and make clear which text is the target of the judgment. I would also retain thread relationships so that one lively discussion doesn't masquerade as fifty independent purchasing experiences.\n\nCollection needs its own implementation, using access appropriate to the source. Jev begins after we have the records; it doesn't solve that access problem.\n\nWhat could the output honestly say? Something like: âWithin this collected set of discussions, maintenance complaints were more frequent among posts describing regret.â\n\nIt could not establish the percentage of all espresso-machine owners who regret buying one. A million comments still reflect the people, communities, search terms, and periods that brought those comments into the dataset.\n\n> Scale makes a sample bigger. It doesn't automatically make it representative.\n\nHonestly, this is where I think the engineering gets interesting. Processing the text cheaply is only one part. Defining what the resulting number means is the part the product has to get right.\n\n## The price changes what is worth trying\n\nAs checked on September 17, 2026, TypeSafe's [parallel-questions cookbook](https://docs.typesafe.ai/cookbooks/parallel_questions) uses a Jev input price of $0.042 per million tokens and zero output-token cost. That is a published pricing assumption, not my measured bill or a guarantee of future pricing.\n\nAt that rate, a hypothetical million records averaging 1,000 total billed
1input tokens each would cost $42 for model input. âTotalâ matters: the budget must include the text, context, questions, and other billed request content. Collection, storage, retries, evaluation, and human review are separate costs.\n\nThat makes a broad enrichment pass worth considering. I would still measure tokens and throughput on a realistic pilot before extrapolating.\n\nThe same cookbook reports roughly 12.2\xd7 lower cost and 10\xd7 lower latency for batching thirteen questions over one document instead of asking them separately. That is a vendor example of batching, not evidence that my million-review pipeline will finish ten times faster.\n\nFor me, the appealing consequence is practical: I can consider retaining several narrowly defined signals per record instead of asking one overloaded question and hoping its answer will support every future analysis.\n\n## Correct types still need correct judgments\n\nTypeSafe's launch discussion distinguishes schema correctness from decision correctness. A model constrained to valid choices can still choose the wrong one. Its âzero hallucinationsâ language should be read in that narrower sense. [Launch explanation](https://typesafe.ai/blog/introducing-system-one-models-and-jev).\n\nI would start with a labeled evaluation set spanning clear examples, mixed opinions, sarcasm, short replies, and the languages actually present in the corpus. For each signal, I would check false positives and missed cases. A packaging-complaint filter and a claim about purchase intent may need different acceptance rules.\n\nA returned probability also deserves validation on that specific task. I wouldn't interpret every 0.9 as a demonstrated 90% correctness rate in my data. TypeSafe's [confidence documentation](https://docs.typesafe.ai/confidence) explains the supplied measures; whether they support my operating threshold still needs evidence from the intended workload.\n\nAmbiguous cases can remain uncertain, go to human review, or receive a second analysis. If I exclude them from a chart, I want their count visible. Quietly dropping the hard cases can make a trend look cleaner than the underlying evidence.\n\nFinally, a writing model should receive computed aggregates, definitions, coverage limitations, and selected source excerpts. It can help explain the results. It should not invent the counts, turn a correlation into a cause, or claim that a few selected quotes prove a population-wide trend.\n\n## The useful thing is what becomes queryable\n\nI keep coming back to that restaurant with ten thousand reviews.\n\nThe review text already contains more detail than the star rating can express. What I want is a way to preserve some of that detail in a form ordinary software can work with, while keeping the original words close enough to challenge the interpretation.\n\nThat is the role I would give Jev: a focused processing stage between human language and the database. The report comes later. First I want to know which columns are worth creating, how reliably we can populate them, and what decisions they actually help someone make.\n\nIf you have a pile of reviews or community discussions that your current dashboard can't explain, I'd be interested to hear which missing column you wish you could query.\n "},{id:"silpo-ai-factory-horeca-procurement-agent",title:"The Cart Was Not the Product: Building a HoReCa Procurement Agent for Silpo AI Factory",excerpt:"For the Silpo AI Factory hackathon, the interesting problem was not building another AI shopping chat. It was figuring out where AI should stop, where deterministic procurement logic should begin, and how MCP could execute a controlled supplier workflow at the end.",tags:["AI Agents","Architecture","Hackathon","MCP","Case Study"],date:"Sep 15, 2026",readTime:"12 min read",coverImage:"/blog/silpo-ai-factory-horeca-procurement-agent/cover.png",published:!0,content:'\nThe obvious idea was also the least interesting one.\n\nThe Silpo AI Factory hackathon gave participants an unusually tempting setup: AI, an official MCP server from a large grocery retailer, product search, carts, delivery, purchase history, and the requirement to build an agent that could actually use tools.\n\nThe easy version almost writes itself. A user asks what to buy. AI suggests products. The app puts them into a cart.\n\nUseful? Maybe.\n\nInteresting? Not enough.\n\nThat shape quickly becomes another AI shopping chat. I wanted to avoid that, partly because the hackathon itself was clearly nudging people toward something more agentic: not text generation around commerce, but a system where the agent receives context, uses tools, and helps complete a concrete task.\n\nThe project that came out of that became a HoReCa Procurement Agent: an AI-assisted procurement cockpit for a small restaurant that also handles catering and events.\n\nLinks for the project:\n\n- Hackathon: [Silpo AI Factory](https://ai-factory.silpo.ua/)\n- Official Silpo MCP: [ai-factory.silpo.ua/docs/mcp](https://ai-factory.silpo.ua/docs/mcp)\n- Live demo: [horeca-nine-alpha.vercel.app](https://horeca-nine-alpha.vercel.app/)\n- Source code: [github.com/MaximusFT/horeca](https://github.com/MaximusFT/horeca)\n- Demo video: [YouTube](https://youtu.be/5D2QP2OFflM)\n\n## The shopping assistant was too small\n\nThe first direction was some version of Smart Basket, AI cart, shopping assistant. It is a reasonable idea. It is also the first idea most people would reach for.\n\nThat was the problem.\n\nI kept asking one question: if I remove the AI chat, what remains as a product?\n\nFor a generic shopping assistant, the answer was not strong enough. The AI was doing too much of the conceptual work. The product was mostly a conversation with a cart mutation at the end.\n\nSo we changed the buyer.\n\nWhat if the customer is not one person deciding what to eat, but a small restaurant or catering business trying to plan procurement?\n\nThat changed everything. A grocery cart stopped being a list of preferences. It became the result of an operational problem: regular restaurant demand, bookings, events, guest counts, menus, recipes, inventory, incoming supply, safety stock, shelf life, and supplier availability.\n\nNow the official commerce MCP had a more precise role. It did n
1ot define the business need. It helped execute a need the system had already calculated.\n\nThat boundary became the project.\n\n## Catering was close, but not enough\n\nThe first stronger version was catering procurement.\n\nWedding. 180 guests. Menu. Recipes. Ingredients. Shopping list. Supplier cart.\n\nThat already felt much better than a shopping assistant. A single change, like 180 guests becoming 200, can flow through portions, recipe quantities, ingredient demand, packages, and cart preparation.\n\nGood demo.\n\nStill not quite the product.\n\nThe flaw was simple: a restaurant continues to exist between events. It still needs vegetables, meat, dairy, eggs, coffee, bread, and all the ordinary supplies of daily operations. Event demand is only one source of demand.\n\nAfter that correction, the model became much stronger:\n\nRegular restaurant demand + event and catering demand + safety stock - current inventory - incoming supply = procurement need.\n\nThen procurement need becomes: when to buy, which supplier products can satisfy it, what package sizes are available, what is in stock, and what should be prepared for approval.\n\nThat is the point where the idea stopped being an event shopping calculator and became a procurement layer for HoReCa.\n\n> The cart is not the product. The procurement plan is the product.\n\nThe cart is the final execution step. The product is the plan that explains what the business needs, when it needs it, and why.\n\n## AI should not do the arithmetic\n\nOnce the product model became clearer, another decision followed naturally: most of the important work was not AI work.\n\nTake the hero scenario. A wedding changes from 180 to 200 guests. The menu includes salmon croissants, cheese croissants, ham croissants, skewers, salads, and dessert cups. If each guest gets 0.35 salmon croissant and each croissant uses a known amount of salmon, that is not a prompt. That is arithmetic.\n\nThe same is true for BOM expansion, unit conversion, inventory subtraction, FEFO, safety stock, package rounding, procurement dates, and plan diffing.\n\nIf an LLM calculates those numbers, the system becomes less reliable, not more intelligent.\n\nSo the procurement engine became ordinary TypeScript code. It owns the critical math: restaurant demand, event demand, recipes, ingredients, inventory lots, incoming supply, shortages, purchase dates, packages, and the procurement plan.\n\nThe AI got a different job: understand natural-language intent, choose application tools, explain where a number came from, help match internal ingredients to supplier products, evaluate replacements, and orchestrate a sequence of safe tool calls.\n\nAI does not calculate procurement. AI helps operate the procurement system.\n\nThat was probably the most important architecture decision in the project.\n\n## Dashboard first, agent second\n\nI also did not want the user to open a procurement system in the morning and ask an AI, "so, what is happening today?"\n\nThe application should already know.\n\nIt should know upcoming events, restaurant load, procurement batches, shortages, expiry risks, incoming supply, and the next actions that need attention. The agent can help explain and act, but the primary interface should be an operations dashboard, not a blank chat box.\n\nSo the UI settled into four main sections: Overview, Procurement, Events, and Inventory.\n\nThe agent did not become a fifth top-level screen. It became a contextual drawer: ask why a quantity is needed, explain the impact of the wedding change, find a replacement, prepare the next supplier order.\n\nThat distinction matters. A chat-first system makes the user discover the product by asking questions. A cockpit should surface the current operating state before the user asks.\n\n## Misto Kitchen made the model concrete\n\nTo avoid building against abstractions, we created a synthetic restaurant: Misto Kitchen.\n\nOne kitchen. Shared inventory. Around 55 seats. Regular restaurant operations. Catering and events. A 14-day planning horizon in the Europe/Kyiv business timezone.\n\nThe demo events gave the system real pressure: Birthday Breakfast, Office Lunch, Private Anniversary, Tech Conference, and Wedding. The wedding became the hero flow: 180 guests to 200 guests, total event guests moving from 445 to 465.\n\nThat tiny number change was useful because anyone can understand it. A manager gets a call: the wedding will have 200 people, not 180. In a manual process, that means recalculating menu portions, ingredients, inventory coverage, packaging, and supplier orders.\n\nIn this system, the change creates a preview first. The user inspects the impact, then approves it. Only after approval does it become Plan v2.\n\nThis is where the product became more than a planning spreadsheet. The system could explain what changed and why.\n\n## Explainability had to be part of the model\n\nOne number is not enough.\n\n
1If the system says "buy 31.4 kg of chicken," a real procurement manager has an immediate question: why 31.4?\n\nSo provenance became part of the domain model. Each procurement line needed to explain its sources: restaurant operations, specific events, current inventory, incoming supply, safety stock, shelf-life rationale, and supplier enrichment state.\n\nThe AI can summarize that explanation in human language. But it should not invent it. The source of truth is the deterministic calculation and structured provenance.\n\nThis is one of those product decisions that looks like UX until you build it. Then it becomes architecture. If explanation is generated after the fact by the model, you get storytelling. If explanation is part of the calculation, you get auditability.\n\n## The first UI was too engine-shaped\n\nAfter the deterministic core started working, the first noticeable product flaw was not in the math. It was in the Overview.\n\nThe screen was too close to the engine. It wanted to show procurement batches, dated ingredient lines, restaurant operating days, and engine status. Those facts were correct, but they did not explain the product to a restaurant manager.\n\nSo the Overview had to be rebuilt around the business story:\n\nRestaurant Operations + Events & Catering -> Combined Business Demand -> Procurement Plan -> Supplier Execution.\n\nThe first viewport needed to show regular operations, the five events, upcoming deliveries, and attention items. Not because those cards are prettier. Because the user has to understand in 10-15 seconds that the system combines daily restaurant demand and event demand into one procurement plan.\n\nThat was a useful reminder: a correct engine does not automatically create a clear product.\n\nThere was also a very normal frontend bug. The Ask Misto drawer first looked trapped inside a narrow header. The issue was not the drawer size. A sticky header used `backdrop-filter`, which changed the containing block for `position: fixed` descendants. The fix was to render the fullscreen overlay through a React portal into `document.body`.\n\nSmall bug. Good lesson. A feature can be logically correct and still be mounted in the wrong DOM and CSS context.\n\n## Mock supplier first, live MCP later\n\nBefore live Silpo access was reliable, we built the full supplier flow against a mock supplier.\n\nThat was not a fake replacement for integration. It was how we protected the product model from waiting on external access.\n\nThe mock supplier covered product mappings for all 38 ingredients, package sizes, prices, availability, deterministic package rounding, surplus and cost, substitution approval, cart preview, cart write, reread, reconciliation, and preservation of unrelated cart lines. The preferred salmon SKU was intentionally unavailable, with a compatible 400 g alternative, so the product could exercise replacement handling.\n\nThe architecture was simple on purpose:\n\nProcurement engine -> SupplierGateway -> Mock supplier or Silpo supplier.\n\nThe mock stayed as an offline fallback. It should never be described as live Silpo.\n\nWhen the agent arrived, it was also intentionally small. One Procurement Agent. Not Inventory Agent, Forecast Agent, Supplier Agent, Cart Agent, and Supervisor Agent. For this MVP, that would have been architecture theatre.\n\nThe agent worked through application-level tools like `get_event`, `get_procurement_plan`, `explain_requirement`, `preview_event_change`, `apply_event_change`, and `prepare_supplier_order`. It did not see the whole raw MCP catalog. It did not own procurement quantities. Every tool called an application use case with its own schema and approval rules.\n\n## Live AI was not the proof\n\nThe default agent mode became `AGENT_MODE=local`.\n\nThat sounds less exciting than a live model, but it was the right default for this project. It was deterministic, free, offline, used the same protected application tools, and made the demo stable enough to record.\n\nThe OpenAI Responses API path was implemented as an explicit opt-in with a limited number of consecutive tool steps. But the final demo should not be described as a fully autonomous live OpenAI session. The API key was not configured in this workspace, and the protocol was covered with mocked HTTP tests.\n\nWhat the demo proves is still valuable: the agentic application flow, the tool orchestration, the approval boundaries, and the supplier execution path. But it is important to say what was actually tested.\n\n## The official MCP changed the shape of the work\n\nThe early generic JSON-RPC client and public docs were enough to understand the direction. They were not enough for a reliable live integration.\n\nAfter access, the real path required OAuth 2.1: Dynamic Client Registration, authorization code, PKCE S256, refresh token, a session-scoped provider, the official `@modelcontextprotocol/sdk`, Streamable HTTP transport, and server-side OAuth state.\n\nThe rule at that stage was simple: do not invent Silpo arguments, fields, or output shapes.\n\nFirst, we captured `tools/list` through an authorized session on September 1, 2026. It returned 40 unique tools, not the 39 suggested by the earlier public de
1scription. One additional branch was `silpo_create_shopping_cart`.\n\nInput draft-07 schemas were saved and validated with Ajv before MCP calls. Output schemas were not treated as known in advance. The mapper only read documented paths and returned safe diagnostics when a shape did not match, without leaking values.\n\nThat is where integration work starts to become real. Not at the first successful tool call. At OAuth, schemas, expiry, idempotency, reread, and observability.\n\n## Read first, then write\n\nThe first live workflow was deliberately read-only:\n\n`silpo_get_my_shopping_cart` -> `silpo_get_shopping_cart_by_id` -> `silpo_get_time_slots` -> `silpo_find_products_batch`.\n\nSearch ran for eggs, tomatoes, and salmon. The workflow stopped before writes if there was no cart, not enough cart context, an invalid delivery slot, or a response shape that did not match documented paths. Express delivery normalized to `DeliveryHome`, because that is what the product search tool description required.\n\nOnly after that came a constrained write spike:\n\npreview -> explicit human approval -> one additive write -> immediate cart reread -> validation.\n\nExisting cart lines could not be cleared or replaced automatically. On September 2, 2026, production confirmed one limited additive write for a test candidate with quail eggs. The sanitized trace showed `silpo_add_or_update_cart_products` followed by `silpo_get_shopping_cart_by_id`.\n\nThe UI reported a verified result only after reread.\n\nThat is not automatic checkout. It is not payment. It is a controlled cart mutation with evidence.\n\n## Serverless made state honest\n\nThe wedding change flow revealed another boundary.\n\nLocally, an in-memory repository was convenient. In production, a memory singleton is not a storage strategy. Different Vercel workers can handle different requests, so planning state and event previews had to move behind a real repository boundary.\n\nThe final approach allowed local development to use in-memory state, while deployed planning state and event previews used Turso. Apply and reread went through the repository boundary, and tests covered preview, apply, and refresh through different repository instances.\n\nThat bug did not invalidate the product model. It made the deployment model more honest.\n\nThe corporate-machine constraints also shaped the workflow. Local coding, tests, and static checks happened on the work machine. Silpo OAuth and OTP happened in a personal browser. Deployed backend calls handled Silpo MCP. GitHub Actions covered sanitized trace and Turso smoke checks. Secrets and raw cart/product values were not routed through the model or stored in traces.\n\nNot glamorous. Necessary.\n\n## Live integration made the mock better\n\nThe supplier-neutral gateway eventually connected the same application contract to the official MCP:\n\nUI -> SupplierOrderService -> SupplierGateway -> SilpoSupplierGateway -> official MCP.\n\nThe first live rollout was bounded. It did not try to send the whole batch. It selected up to three light, fully fulfillable lines within a ten-line window, with total mass or volume capped at 20 kg/l, far below the observed 50 kg Silpo delivery validation. The cap was checked before preview and before write.\n\nThis was a pilot, not a claim that the entire procurement batch could safely go to Silpo.\n\nThe gateway accounted for display ratio, step, price, stock, package rounding, product IDs already in the cart, error-level cart validations, and reread after write. After live verification, matching became stock-aware and started skipping SKUs that could not fully satisfy the need.\n\nThe salmon replacement case was especially useful. In the mock catalog, the preferred salmon SKU was unavailable, so the offline flow showed a
1substitution. In live Silpo, `silpo_get_replacements` for sampled salmon returned success with an empty list.\n\nThat empty list was not an error. It meant there was no known picking or assembler risk for that product. The system had to accept reality instead of inventing a replacement for a prettier demo. Unresolved lines blocked cart preview. Unknown nested shapes did not become synthetic replacements. Exact nested replacement mapping would only be added after a separate capture.\n\nThe integration became more reliable because it allowed the real response to be boring.\n\n## The failures after live verification were the useful ones\n\nSeveral issues appeared only after the live path existed.\n\nA delivery slot could expire between preparation and apply, so the system needed recovery: reread cart, fetch available slots, preview alternatives, require approval, update the slot, reread the exact slot, and retry supplier preparation.\n\nA repeated demo run could accidentally double the real cart quantity. Reset Demo must not delete real Silpo cart lines, so the gateway now reads existing product IDs before supplier initialization and stops before preview/write if a selected SKU is already present.\n\nInsufficient stock also changed matching. A known product is not enough if there is not enough stock to fulfill the required quantity.\n\nLater, i18n exposed a runtime boundary: a Dictionary with interpolation functions cannot be passed from a Server Component to a Client Component as a plain prop. Typecheck and build passed, but the browser failed. The fix was to pass a plain locale to client components and let them read a client-safe dictionary import. Backend locale also flows into agent explanations and supplier flow.\n\nThese are not glamorous failures. They are the difference between a demo that works once and a workflow that can be trusted enough to show.\n\n## Observability had to be safe\n\nA normal browser cannot directly show a server-to-server call from Vercel backend to `mcp.silpo.ua`. It only sees requests to the Next.js routes.\n\nSo the demo needed a sanitized Agent request execution timeline.\n\nIt combines application decisions, application tool calls, official Silpo MCP calls, sequence number, status, and duration. In mock mode, the timeline shows only application steps. In deployed Silpo mode, it adds blue Silpo MCP entries from the server-side trace.\n\nThat mattered because a reviewer needs to see where the application made a decision and where the official supplier tool was actually called. But the trace stays safe: operation names, argument key names, status, duration, and structural summary. Not tokens. Not raw arguments. Not addresses. Not cart contents. Not product values.\n\n## What the final demo actually proves\n\nThe final demo flow was intentionally short.\n\nOverview shows Restaurant Operations, Events & Catering, Combined Procurement, a 14-day timeline, and attention items. Wedding starts at 180 guests. The user changes it to 200, reviews the preview, sees Plan v1 become Plan v2, and approves. The system updates total guests to 465 and records the change.\n\nThen the user opens Why this quantity? and sees deterministic provenance: demand sources, inventory coverage, incoming supply, and timing. Finally, Ask Misto prepares the next supplier order, shows a bounded supplier card, handles availability/substitution state, creates a cart preview, requires approval, performs an additive write, rereads the cart, and reports a verified result.\n\nThe right ending is not "AI ordered everything."\n\nThe right ending is: the agent guided the user from a business event change, through an explainable procurement plan, to a verified supplier cart, while keeping business-critical mutations under human control.\n\nTesting followed the same philosophy. Unit and application tests covered demand calculation, BOM expansion, FEFO, incoming supply timing, package rounding, provenance, wedding preview/apply, stale preview protection, agent tool schemas, mock supplier flow, reread reconciliation, live schema validation, timeslot updates, sanitized MCP trace, and Turso repositories.\n\nThe handoff baseline had 122 tests in 36 files. Static checks included `npm test`, `npm run typecheck`, `npm run lint`, `npm run build`, and `npm audit`, with audit reporting 0 vulnerabilities after adding the MCP SDK.\n\nBrowser QA covered the wedding flow, explanation drawer, mock salmon substitution, cart preview -> approval -> reread, agent supplier preparation, expired slot recovery, mobile/tablet navigation at 850 px and 390 px, no page-level horizontal overflow, procurement search, expiry-risk filtering, and localized units.\n\nProduction Silpo verification confirmed live OAuth, 40 tools from `tools/list`, cart context, approved timeslot update, batch product search, one explicitly approved additive product write, immediate reread and validation, a bounded supplier flow through the normal Procurement Agent UI, one `silpo_add_or_update_cart_products` call followed by `silpo_get_shopping_cart_by_id`, and preservation of existing cart lines.\n\nIt did not prove everything. It did n
1ot test all 40 tools, full checkout, payment, full wholesale ordering, every replacement response shape, live OpenAI autonomy, or real restaurant usage.\n\nThat distinction is the whole point.\n\n## What I would keep\n\nThe hardest part of this project was not connecting an LLM to a store.\n\nThe hardest part was deciding what work the AI should not do.\n\nThe procurement engine calculates. The plan explains. The UI surfaces the operating state. The agent interprets intent and orchestrates tools. The supplier gateway executes bounded supplier operations. MCP is the final execution layer, not the product itself.\n\nThat boundary made the project more interesting than another shopping assistant.\n\nSilpo is a connected commerce supplier in this story. I am not presenting it as a full HoReCa wholesale ERP, and the MVP is not a multi-supplier procurement platform. The future architecture could absolutely grow that way: Silpo, Metro, meat suppliers, vegetable suppliers, local suppliers, with AI helping compare availability, delivery, price, quality constraints, and preferences.\n\nBut that was not the MVP.\n\nThe MVP was narrower and, honestly, stronger because of it: one restaurant, one kitchen, one procurement plan, one agent, one supplier integration, explicit approvals, idempotent writes, reread validation, and a trace that shows what happened without leaking sensitive values.\n\nIf there is one thing I would take from the hackathon, it is this:\n\n> Real agentic software is not proven by a model calling a tool. It is proven by the system around that tool: deterministic logic, explainable state, preview, approval, idempotency, reread, durable storage, and a clear boundary between application and supplier.\n\nThat is where the work became interesting. Not when the cart changed. When the cart became only the last step of a procurement workflow the system could explain.\n'},{id:"nivra-webmcp-challenge",title:"Nivra: Turning a Hackathon Idea into a WebMCP Architecture Workspace",excerpt:"How Nivra went from a vague hackathon direction to a tested WebMCP workspace: choosing the right idea, making architecture the shared context, and proving that human UI, agent tools, and deterministic validation could work from the same model.",tags:["AI Agents","Architecture","Hackathon","WebMCP","Case Study"],date:"Sep 11, 2026",readTime:"10 min read",coverImage:"/blog/nivra-webmcp-challenge/cover.png",published:!0,content:'\nHackathons usually start with a dangerous question: what can I build quickly?\n\nI don\'t like that question very much. It sounds practical, but it pushes you toward the wrong kind of demo: a screen, a button, a little AI, and the feeling that something product-shaped exists. For a normal hackathon, maybe that is enough. For the OpenAI WebMCP Challenge, it wasn\'t.\n\nThe better question was narrower:\n\nWhat idea actually shows why WebMCP should exist?\n\nNot "how do I add AI to an app." Not "how do I put a chat panel next to a UI." I mean: where does an agent need to read the state of the application, move through the same workspace as the human, act on the same model, and help expose something that would otherwise stay hidden between layers of the system?\n\nThat question became Nivra.\n\nLinks for the project:\n\n- Live app: [nivra-psi.vercel.app](https://nivra-psi.vercel.app)\n- GitHub repository: [github.com/MaximusFT/nivra](https://github.com/MaximusFT/nivra)\n- Demo video: [YouTube](https://youtu.be/BSz_Aqm4Ius)\n\n## The first ideas were not architecture\n\nAt the beginning, I did not have the project. I had several possible directions, and we rejected most of them for reasons that were more useful than the ideas themselves.\n\nOne strong candidate was Senwia Sleep Lab, an evolution of an app idea I had around sleep analysis. I still like that direction. Sleep is understandable. The data can be visual. An AI agent could explore patterns with the user, highlight anomalies, ask questions, and help connect habits with outcomes.\n\nBut for this challenge, it had a weak point. To make the demo convincing, I would have needed a
1synthetic data layer: fake sleep history, fake events, fake metrics. WebMCP would have interacted with data I had invented for the demo. The product might have been interesting, but WebMCP would not have been the reason it worked.\n\nThere were other options too. Interview Forge, an AI tool for technical interviews. AI Newsroom, a system for processing and organizing news. And then an architecture workspace: a place where a human architect and an AI agent could work together over a structured model of a software system.\n\nThat last idea kept surviving the cuts.\n\nNot because architecture sounds more serious. Because WebMCP was not decorative there. If an agent is working with architecture, it needs to inspect application state, move between views, find dependencies, show evidence, add findings, create proposals, and work inside the same workspace the architect is looking at.\n\nThat is a different product shape. WebMCP stops being a chat next to a diagram. It becomes the agent\'s interface into the architecture model.\n\n## ArchPilot was the wrong name, usefully\n\nThe first working name was ArchPilot.\n\nIt sounded fine at first. Short, obvious, close enough to architecture and agent-assisted work. Then we checked the market and found existing products with similar names, including products in software architecture and architecture governance.\n\nSo the name had to go.\n\nThat could have been just a naming problem. It turned into a product decision.\n\nWhen a name stops working, you have to ask what you were actually trying to name. And that forced a clarification: Nivra should not be "AI designs architecture for you." It should not be an architecture validator that stands above the system and declares what is correct. It should not be a governance platform. And it definitely should not be a chat box glued to the side of a diagram.\n\nI needed a different sentence:\n\n> Architecture is the shared context.\n\nThat became the center of Nivra.\n\nThe human and the AI agent work with the same structured architecture model, but through different interfaces. The human sees a visual workspace. The agent gets access to the same model through WebMCP tools. One source of truth. Two ways to reason over it.\n\n## The demo needed a real architectural tension\n\nOnce the product shape was clear, the next problem was the scenario.\n\nI did not want a toy example where service A talks to service B and the whole point is that the arrow is bad. That kind of demo teaches the user to agree with the author, not to inspect the system.\n\nThe scenario became Commerce Platform.\n\nAt the High-Level Design level, everything looks almost right. Product, Cart, Checkout, and Account are separated into microfrontends. Checkout looks independent. You can draw a box around it and say, quite reasonably, "this is a separate part of the system."\n\nThen the business requirement lands: the Checkout Team needs to evolve and deploy Checkout independently from Product.\n\nThe natural reaction is understandable:\n\n> Checkout is already a separate microfrontend. Why wouldn\'t it be independently deployable?\n\nThat is exactly where Nivra needed to become useful.\n\nAt HLD, Checkout looks ready. But when the agent moves deeper into Checkout LLD, two dependencies appear, and they are not the same kind of problem.\n\nThe first is `Basket Adapter -> Product Store` through shared runtime state. That is runtime coupling. Checkout may be visually separate, but it still depends on Product state at runtime.\n\nThe second is `Pricing Module -> Product Service` through an explicit REST API. That is also a dependency, but it is visible, named, and easier to reason about. An architect may decide it is acceptable for now.\n\nThat distinction became the core of the demo:\n\n> A separate box on a diagram doesn\'t mean an independent system.\n\nI did not want Nivra to say "all dependencies are bad." That is not architecture. The useful moment is when the human sees the evidence, understands the difference between shared runtime state and an explicit contract, and makes a trade-off.\n\nThe agent does not decide for the architect. It helps expose the layer where the decision actually lives.\n\n## The canvas could not be the source of truth\n\nAfter the scenario clicked, we designed the product architecture around it.\n\nThe most important decision was boring on purpose: do not start with the diagram.\n\nIf the first thing you build is a React Flow canvas, it is very easy to confuse the graph with the architecture. The nodes on the screen start to feel like the system. Coordinates acquire meaning. Views drift into their own copies of elements. Then validation, WebMCP, and UI each interpret the "architecture" slightly differently.\n\nSo the first real artifact was a plain TypeScript Architecture Model. Commerce Platform v1.35 became a deterministic fixture model, not a decorative picture. HLD and Checkout LLD referenced the same entities and relations. Views stored IDs, not duplicated elements. Layout lived separately, because canvas coordinates are presentation metadata, not architectural meaning.\n\nThat decision paid for itself later. UI, validation, and WebMCP could all work against the same model independently. If the canvas changed, the architecture still existed. If the visual language changed, the domain model did not move with the pixels.\n\n## The plan came before the code\n\nAfter the product idea settled, we still did not jump straight into implementation.\n\nFirst came the Product Definition and MVP scope. Then Architecture Levels and the Domain Model. Then the WebMCP Tool Contract, UI/UX, technical implementation notes, demo fixture, golden scenario, seven-day implementation plan, and visual design specification.\n\nAll of that became Nivra Master Specification v1.1.\n\nAt that moment, Nivra still was not an application. But it was no longer just an idea. It had a scenario, a domain model, a WebMCP contract, a UI direction, a demo fixture, and a development plan.\n\nThe question changed.\n\nIt was no longer: what should we build?\n\nIt became: can we make this model actually work?\n\n## Foundation first, WebMCP later\n\nOnce the master specification existed, the temptation was obvious: start with tools. This was the WebMCP Challenge, after all. Show WebMCP.\n\nBut that would have been the wrong order.\n\nWe started with React, TypeScript, and Vite, but the real output of the first phase was not a screen. It was the Architecture Model and the operations around it.\n\nOnly then came the canvas, adapters from the Architecture Model into React Flow, and navigation from Commerce HLD into Checkout LLD.\
1n\nThat became Checkpoint A: does the product create a "now I see it" moment without the agent telling the user what to think?\n\nAt HLD, Checkout looked separate. In LLD, the shared runtime state dependency and the explicit REST call appeared together. The relations had different visual and semantic treatments. The product idea finally stood on the workspace itself, not on narration around the demo.\n\nIf a demo only works while I am explaining it out loud, the product does not work yet.\n\n## The first serious bug was not where I expected\n\nThe early version compiled. Then a browser smoke test immediately found a React maximum update depth problem, effectively an infinite render.\n\nThe cause was not the Architecture Model. It was an object-returning Zustand selector. The selector created a new object, React treated it as a new value, the component updated, the selector created another new object, and the loop continued.\n\nThe fix was ordinary: stable comparison with `useShallow`.\n\nThe lesson was larger than the fix. Even a foundation screen cannot be considered successful just because TypeScript is satisfied. The browser has its own failure modes: selectors, subscriptions, effects, render timing. If an architecture workspace is supposed to be trustworthy, it needs to be exercised in a live UI early.\n\nAround the same time, another risk became obvious: the canonical fixture could not be mutated directly. If future write actions could silently change Current Architecture, the whole proposal story would lose credibility. Changes had to be immutable. Current Architecture had to remain the reference point.\n\nThat sounds like an implementation detail, but it is really a product detail. The user has to know what they are looking at: the current system, or a proposed alternative.\n\n## The reasoning loop had to work manually first\n\nBefore registering any WebMCP tools, we made the manual scenario work end to end.\n\nThe architect selects evidence, opens the Context Panel, sees relations and deployment information, creates a Finding, saves architectural constraints, runs deterministic validation, creates a Proposal, compares Current and Proposal, and validates the alternative.\n\nThat was Checkpoint B: the full manual Observe -> Question -> Inspect -> Understand -> Decide -> Propose -> Verify loop.\n\nThis is where the product became clearer to me: validation was not the main product.\n\nValidation matters. Without it, the whole thing becomes a nice story. But Nivra is not centered on pass/fail. It is centered on shared understanding. The agent can help inspect, explain, drill into LLD, and propose changes. But once a rule is formalized, boring deterministic code should check it.\n\nAgent-assisted reasoning. Deterministic verification.\n\n## Constraints needed evidence\n\nWe added four rule types: `forbidden-dependency`, `independent-deployment`, `no-cycles`, and `allowed-protocol`.\n\nValidation was pure, typed, and evidence-bearing. Each check returned not only pass/fail, but references to the elements or relations that explained the result.\n\nGolden Current Architecture returned 2 passed / 2 failed.\n\nHonestly, that was better than making the demo pass perfectly from the beginning. If Current passes every rule, validation proves very little. It might just be confirming a happy path written for the demo. The 2/2 result showed that the rule was actually finding the hidden violation.\n\nSome details mattered. A forbidden dependency has to account for descendants, not only exact IDs. LLD elements inherit deployment unit through the parent hierarchy. Protocol comparison is case-insensitive, even if the display model says REST. Validation does not create Findings, and it does not depend on React, Zustand, or the canvas.\n\nAn architecture rule should validate architecture, not whatever the UI happens to be rendering.\n\n## Proposal, not mutation\n\nThe next problem was trust.\n\nIf an agent can "fix" the architecture directly, the human loses the ability to understand the original state. What was Current? What did the agent propose? What has been accepted? What is still just an alternative?\n\nSo Current Architecture stayed immutable. A Proposal became a patch-based alternative with base version checking.\n\nIn the golden proposal, the runtime state dependency is removed and replaced with a Checkout Snapshot Contract. Not as a magical fix, but as a checkable architectural alternative.\n\nThe visual diff needed thought too. The added Snapshot Contract and the removed Product Store/runtime relation had to be visible at the same time. The effective Proposal view had to be computed from the patch, not stored as a separate diagram, because otherwise we would be back to two competing models instead of one source of truth.\n\nThe result:\n\nCurrent: 2 passed / 2 failed.\n\nPro
1posal: 4 passed / 0 failed.\n\nThat felt like architecture work. The human can see the trade-off, inspect the evidence, and evaluate a verified alternative before accepting anything.\n\n> The agent doesn\'t draw architecture for you. It reasons inside your architecture with you.\n\n## WebMCP as an adapter boundary\n\nOnly after manual Checkpoint B did we start the WebMCP integration.\n\nThat order mattered. WebMCP tools did not get their own architecture model. They did not mutate React Flow directly. They became a browser adapter over existing workspace operations. The human interface and the agent interface called the same logic.\n\nThe first group of tools covered reading and navigation: `get_architecture`, `inspect_element`, and `show_architecture_view`.\n\nThen came write and compute tools: `annotate_architecture`, `add_constraint`, `create_proposal`, and `validate_architecture`.\n\nSeven P0 tools in total.\n\nOn paper this sounds simple: write a JSON Schema, register a handler, done. In practice, the boundary was more interesting. The runtime can call a handler more than once. It can pass malformed data. It can send an ID that looks plausible but breaks the model. So the write tools also validate stable kebab-case IDs, enums, evidence references, duplicate and conflicting IDs, parent references, relation endpoints, proposal base version, and nested update values.\
1n\nEvery call creates an activity entry with running, success, or error status. The human can see what the agent actually did in the workspace. But activity is not persisted as architecture state.\n\nThat separation matters. Agent activity explains the process. It is not the architecture.\n\n## Experimental API means experimental\n\nWebMCP was an experimental browser API available through a specific Chromium mode. A normal browser might not have `document.modelContext`. That could not be treated as the whole app failing.\n\nSo Nivra got an explicit WebMCP ready / WebMCP unavailable status, a guarded legacy fallback for earlier preview runtimes, AbortSignal-based registration, a complete manual fallback without WebMCP, and a guided demo simulation for cases where an external agent is not available.\n\nThe honesty of that simulation mattered.\n\nIt is labeled Demo simulation. It uses the same workspace actions and shows the same operations, but it does not pretend to be real AI. Challenge V1 is a client-side application. Not a production AI backend. Not real-time collaboration. Not a governance platform.\n\nIt is a verifiable workspace where WebMCP has a clear role when the runtime is available, and the product still works when the runtime is not.\n\n## Unit tests do not prove a browser tool works\n\nUnit tests protected domain queries, adapters, validation, persistence, store actions, and tool input validation.\n\nThat was necessary. It was not sufficient.\n\nA handler test does not prove that the browser registered a WebMCP tool. It does not prove that transport reached a workspace action. It does not prove that the human saw the result in the UI.\n\nSo we built a black-box harness: `npm run test:webmcp`.\n\nIt starts an isolated Vite server, launches a temporary Chromium profile with WebMCP enabled, discovers all seven tools, and calls them through browser `ModelContext.executeTool()`. Every run starts from Reset Demo. The harness intentionally repeats Finding, Constraint, and Proposal writes, checks DOM focus and Agent Activity, validates Current and Proposal, returns to Current, and confirms that the original `basket-adapter-shares-product-store` relation is still there.\n\nThree full repetitions returned the same result.\n\nThat was Checkpoint C: Current 2/2 -> Proposal 4/0. Current stayed unchanged. Retries were idempotent. Activity was visible. No errors.\n\nThat finally felt like evidence. Not "it clicked locally." Not "the handler returns JSON." The full chain worked: WebMCP registration -> browser transport -> workspace action -> visible React state.\n\n## Polish was not about making it pretty\n\nAfter functional freeze, we did not expand scope. We asked a different question: can someone understand the story without me narrating it?\n\nThe answer was: not quite yet.\n\nHLD/LLD navigation could not look like unrelated tabs. Selecting an element had to open its own context, not accidentally show Checkout policy. Proposal action should not appear before Current validation exposes a problem. Added and removed evidence needed to be visible together. Agent Activity had to be readable without taking over the workspace. The standalone demo could not pretend it had a live agent connection.\n\nSo we moved toward contextual drill-down and breadcrumb navigation. We added a neutral Policy state without selection, scoped Checkout policy, timeline Agent Activity, guided demo, and a dismissible history drawer.\n\nAfter successful Proposal validation, the workspace also got an implementation brief and the ability to save a Proposal as an architecture branch.\n\nImportant detail: architecture branch is not a Git branch. It is a durable verified alternative inside Nivra. `current/commerce-1.35` and `proposal/checkout-isolation` can be compared without overwriting Current.\n\nThe visual verification was practical: desktop layouts at 1440x900 and 1920x1080, no horizontal overflow, readable Checkout LLD evidence and Agent Activity, clean browser console.\n\n## Production does not prove WebMCP\n\nAfter local stabilization, the app went live on Vercel: [nivra-psi.vercel.app](https://nivra-psi.vercel.app).\n\nDeployment then became Git-driven: pushing to `main` runs the production build through the connected GitHub repository. But deployment itself proves almost nothing about WebMCP.\n\nThe app can load over HTTPS, the UI can look fine, and browser tool registration can still be broken. So the same black-box harness was run against the public HTTPS URL through `NIVRA_WEBMCP_URL`.\n\nThree production repetitions confirmed the same behavior: all seven tools were registered, Checkout LLD evidence opened and focused, Current returned 2 passed / 2 failed, Proposal returned 4 passed / 0 failed, retries stayed idempotent, Current was not polluted by proposal-only state, and Agent Activity stayed visible without error state.\n\nBy the end, there were four layers of verification: TypeScript typecheck, unit tests, and production build; manual browser checkpoints for HLD/LLD and the reasoning loop; the WebMCP-enabled Chromium black-box harness; and the same checks against production HTTPS.\n\nOnly after that did I consider Nivra ready for demo and submission.\n\n## What Nivra became\n\nNivra started with a question: what idea is worth building for the WebMCP Challenge?\n\nBy the end, the question had changed. Nivra had become a concrete system with a source of truth, a reasoning loop, browser tools, deterministic validation, and a reproducible demo state.\n\nIt did not become an automatic architect.\n\nGood.\n\nIt does not draw architecture instead of the human. It gives the human and the agent one shared context where they can expose a hidden depen
1dency, define a rule, propose an alternative, and verify it without silently replacing Current Architecture.\n\nAt that point, the project stopped being just a concept. The human could see the architecture. The agent could work with the same model. Deterministic checks prevented a pretty proposal from quietly becoming the current system.\n\nYou can try the live app, read the source, or watch the demo here:\n\n- [Live app](https://nivra-psi.vercel.app)\n- [GitHub repository](https://github.com/MaximusFT/nivra)\n- [YouTube demo](https://youtu.be/BSz_Aqm4Ius)\n\nThe next step was no longer deciding what to build. It was showing whether this combination â human, agent, WebMCP, and a shared Architecture Model â could hold up live, in front of someone who had not watched the whole thing being built.\n'},{id:"agentic-development-at-work",title:"Earned Autonomy: Comparing My Agent Workflow with a Corporate Experiment",excerpt:"I arrived at Liberty with a working model for agentic development. Now the company is exploring a more formal approach built around reusable agent skills. I don't know which model will prove better yet, and that uncertainty is the interesting part.",tags:["AI Agents","Architecture","Engineering Workflow"],date:"Sep 16, 2026",readTime:"11 min read",coverImage:"/blog/agentic-development-at-work/cover.png",published:!0,content:"\nI came to Liberty roughly six months ago with a fairly strong opinion about what an AI coding agent should be doing.\n\nNot autocomplete. Not a faster way to generate a function. Not a chat window that occasionally knows how to edit a file.\n\nI had been building a working environment in which an agent could participate in most of the delivery lifecycle: understand a task, inspect the code, make a plan, change a small slice, run the right checks, prepare a pull request, and help work through what happened afterwards.\n\nNow Liberty is starting to explore a more formal way of working with agents. The approach uses reusable agent skills and a more explicit, specification-driven sequence around planning, implementation, validation, and learning.\n\nI find the comparison more useful than either approach in isolation. I already have a workflow that works for me. The company experiment gives me a chance to ask a harder question: which parts of that workflow are genuinely valuable, and which parts only feel valuable because I built them myself?\n\n> The interesting question is not whether an agent can act autonomously. It is whether its autonomy has been earned by context, rules, and evidence.\n\n## What I brought with me\n\nMy approach is based on earned autonomy.\n\nThat phrase matters. I don't mean giving an agent unlimited freedom and hoping that a capable model will make good decisions. I mean giving it room to move inside boundaries that are explicit enough to be checked.\n\nIn a typical task, the agent starts with a goal from a ticket or a conversation. It researches the relevant code and nearby context. It tries to find the code that actually controls the behavior instead of editing the first file that looks related. It identifies constraints, forms a local hypothesis about how the change should work, and chooses a cheap check that could prove the hypothesis wrong.\n\nThen it changes a small slice and validates that change immediately. Broader checks come afterwards. When the work is ready, the agent can prepare a branch and a draft pull request, help investigate CI failures, and respond to review feedback.\n\nThe important part is not the length of that list. It is that the list describes a connected workflow rather than a collection of isolated prompts.\n\n### Context before cleverness\n\nThe workflow depends on several levels of context. There are personal rules about how I want an agent to work, shared engineering standards, and repository-specific instructions. More local and more current information should win over a general preference.\n\nMemory helps the agent continue across sessions, but memory is not authority. The current code, tests, documentation, and actual tool output are more trustworthy than something saved from a previous conversation.\n\nThat distinction is easy to state and surprisingly important in practice. A remembered convention can be stale. A test failure in front of you cannot be argued away by a paragraph written last month.\n\n### Tools instead of context theatre\n\n
1I also prefer integrations over manually copying the world into a prompt. When permitted, the agent can work with task systems, documentation, source code, tests, and pull requests through connected tools.\n\nThe benefit is not that the agent suddenly knows everything. It is that the relevant evidence can stay close to the task. Requirements, implementation, and validation do not have to be reconstructed from a chain of pasted fragments every time.\n\nThat still requires judgment. An integration can expose the wrong context just as efficiently as the right one. Tools expand an agent's reach; they don't decide what deserves attention.\n\n### The human is still the owner\n\nAn agent can research, edit, test, and prepare changes. It does not own the requirements, the architecture decision, the approval, or the final review.\n\nThat is not a ceremonial disclaimer. It is part of the design. Autonomy is useful when it removes mechanical coordination and preserves human attention for decisions that carry real risk. If a workflow asks a person to approve every harmless step, it has not created autonomy. It has created a queue of tiny approvals.\n\n## What Liberty is exploring\n\nThe approach Liberty is beginning to investigate puts more structure around the work. It uses reusable agent skills: packages of instructions and working patterns that help an agent perform a particular kind of engineering task more consistently.\n\nA skill might support context discovery, planning, implementation, debugging, or review. It is not another intelligent employee. It is a reusable description of how to approach a task, with the aim of making useful practices easier to repeat across engineers and repositories.\n\nThe broader lifecycle is more explicit than the one I normally follow informally:\n\n1. Prepare the relevant instructions and context.\n2. Clarify or create a specification.\n3. Produce a plan.\n4. Review the plan.\n5. Implement the change.\n6. Validate the result.\n7. Record observations about quality and usability.\n\nThere is a lot to like here. A team cannot rely on one engineer having spent months tuning a personal workflow. Reusable skills offer a possible common language. They might help engineers with different levels of experience start from a better baseline, and they create a clearer shape for comparing what happened across tasks.\n\nThe experiment is still early. Liberty is exploring and piloting this kind of systematic approach; it has not produced a final company-wide process or a conclusion about fully autonomous software development. That distinction matters because the value of the experiment is precisely that the result is not known yet.\n\n## Where the approaches meet\n\nThe overlap is larger than the difference.\n\nBoth approaches assume that an agent needs good context before it needs more freedom. Both treat clear requirements, planning, small changes, tests, and human review as controls rather than optional polish. Both value reusable knowledge, because repeating the same explanation in every session is a poor use of anyone's time.\n\nBoth also suggest that speed of generation is an incomplete measure. A change that appears quickly but needs extensive correction is not necessarily faster. The useful question includes quality, traceability, review effort, and what happens when the next engineer has to understand the result.\n\nThat is why I don't see reusable skills as the opposite of my workflow. They may be a way to package some of its useful habits so that they are less dependent on personal configuration. They may also expose parts of my process that are too implicit to teach to anyone else.\n\n## Where I am cautious\n\nThe concerns I have are hypotheses, not verdicts. The experiment needs to test them.\n\nThe first is process proportionality. A full specification, plan, review, implementation, and reporting sequence makes sense for risky or complex work. For a tiny, obvious change, it may create more text and more transitions than value. A good engineering process should scale with the risk of the task, not apply the same ceremony to everything.\n\nThe second is that artifacts can become the goal. A detailed specification can still describe a wrong assumption. An agent can produce a polished plan for a problem nobody actually has. Evidence and useful questions matter more than document length.\n\nThe third is the cost of formal stopping points. If a person must confirm every safe step, the workflow can become a stream of interruptions. My current model delegates many low-risk decisions through standing rules and brings a human in where the risk changes. I want to know whether a more formal process can preserve that shape.\n\nThere is also a locality problem. A reusable skill can explain how to plan or validate in general, but it cannot automatically know a repository's architecture, conventions, environmental limits, or history of decisions. Without that local layer, a skill can produce work that sounds reasonable and still does not belong in the system.\n\nFinally, attribution will be difficult. If a task goes well, the cause might be the skill, the repository instructions, the connected tools, the tests, the quality of the original request, or the engineer's own experience. A fair experiment has to separate those effects as much as it reasonably can.\n\nReporting has a cost too. Tracking corrections, defects, rework, and subjective experience is necessary for an honest comparison. But if reporting takes a meaningful amount of engineering time, that time is part of the result, not an inconvenient detail to hide.\n\n## What I want to learn\n\nI am not joining this experiment to prove that my existing approach is already the answer. That would make the comparison pointless.\n\nI want to find out which skills prevent mistakes that would otherwise reach review. Where does an explicit specification improve the result, and where does it add ceremony? Does the process help an agent recover context in a new session? How much human correction is actually required? What happens to review quality and cost?\n\nThe useful comparison will come from real tasks, not from a polished demonstration. For each task, I would want to understand whether the acceptance criteria were met, how much correction and rework appeared, which defects were found by the agent, CI, or a person, and which parts of the framework produced observable value.\n\nI would also separate ordinary engineering effort from framework overhead and reporting time. Those are different c
1osts. Combining them into one number would make the result look cleaner while making it less informative.\n\nThe data will need to be aggregated and anonymized. The goal is to learn about the workflow, not to publish internal details or turn a small experiment into a claim about the whole company.\n\n## This is the beginning, not the conclusion\n\nThere is a temptation to choose a winner early. Personal workflows feel concrete because you can see their history. Formal frameworks feel credible because they have names, stages, and reusable artifacts. Neither feeling is evidence.\n\nMy current expectation is that the useful outcome will be a combination. Some skills may capture practices worth making more consistent. Some formal stages may be valuable only for certain classes of work. Some of my existing rules may turn out to be personal preferences rather than durable engineering principles.\n\nThat is a good result. The point is not to protect a workflow because I built it, or to accept a framework because it arrived with a process diagram. The point is to discover which controls help an agent do better work while leaving people responsible for the decisions that matter.\n\nI am at the beginning of that comparison now. Later, once there is enough evidence and the results have been checked for accuracy and confidentiality, I'll come back with what actually happened: where reusable skills helped, where they added friction, and what I changed in my own way of working.\n\nUntil then, I am keeping the conclusion deliberately open. An agent does not become trustworthy because it has more freedom, more documents, or more steps. It becomes more trustworthy when the system around it makes good decisions easier to verify.\n\nIf you've been comparing a personal agent workflow with a more formal team process, I'd be interested in what you measured. The difference between a convincing demo and a useful engineering system usually appears only after the first few ordinary tasks.\n"},{id:"react-playbook-micro-frontends-org-chart",title:"Micro-Frontends Are an Org Chart Decision, Not a Technical One",excerpt:"Every micro-frontends pitch I've seen leads with the technical benefits â independent deploys, framework isolation, smaller bundles per team. Almost none of it holds up unless the real problem was organizational to begin with. Conway's Law isn't a fun fact here. It's the actual justification.",tags:["React","Architecture","Micro-Frontends","React Playbook"],date:"Sep 14, 2026",readTime:"10 min read",coverImage:"/blog/react-playbook-micro-frontends-org-chart/cover.png",published:!0,content:"\nI've sat through more than one micro-frontends pitch, and they all open the same way: a slide about independent deployments, a slide about teams not blocking each other, maybe a slide about being able to use a different framework per app \"if we ever need to.\" It's a technical pitch, delivered with technical slides, aimed at a technical audience.\n\nHere's the thing I think gets buried under all of that: almost none of it is actually a technical problem. It's an organizational one wearing a technical costume, and the pitch works better when it's honest about that from the start.\n\n> You don't split a frontend into micro-frontends because the code is too big. You split it because the *teams* touching that code can no longer coordinate at the speed the org needs.\n\nThat's not a knock on micro-frontends. It's a claim about what actually justifies them â and it means the right first question isn't \"is our bundle too big\" or \"do we need framework flexibility.\" It's: does our org chart already have the shape that micro-frontends are supposed to reflect?\n\n## What the technical benefits actually cost\n\nEvery micro-frontend benefit on the slide has a real, un-glamorous cost that doesn't make it into the pitch.\n\n**Independent deploys** sound great until you notice that a single-repo monolith with a decent CI pipeline can *also* deploy independently deployable features behind flags, without ever splitting the app. What micro-frontends actually buy you over that is independent deploys *without needing anyone else's review or coordination* â which is a genuine win, but only if the reason you needed that isolation was that coordinating with the other team was the actual bottleneck, not the deploy pipeline itself.\n\n**Framework isolation** â \"team B can use Vue while team A uses React\" â sounds like flexibility and is usually a cost nobody priced in. You now ship two frameworks' runtimes to every user, you've doubled your hiring and knowledge-sharing surface, and the actual reason teams reach for this is almost never \"Vue is better for this specific problem.\" It's normally \"team B inherited a Vue app during an acquisition and nobody wants to rewrite it.\" That's a real reason. It's an organizational one, not an argument that framework diversity is good architecture.\n\n**Smaller bundles per team** is the one that sounds most like a real technical win, and it's the one I'd push back on hardest. Splitting an app into micro-frontends doesn't shrink the total code the user downloads â it usually grows it, because now you're shipping duplicated dependencies (React itself, your design system, your date library) once per micro-frontend unless you invest heavily in shared-dependency tooling, which is its own significant ongoing cost. If bundle size is y
1our actual problem, code-splitting inside a single app solves it directly, cheaper, and without any of what follows in this article.\n\n## The cost nobody puts on the slide\n\nOnce you've actually split the frontend, a list of problems shows up that no diagram warned you about.\n\nCross-app state gets hard in a way that's easy to underestimate. A shopping cart, a logged-in user, a feature flag â anything that needs to be consistent across micro-frontends now needs a real synchronization mechanism, and \"we'll just use custom events\" is the sentence every team says right before building an ad hoc, under-tested message bus by accident.\n\nDesign system drift becomes a constant, low-grade tax. Each micro-frontend can update its dependencies, including your shared UI kit, on its own schedule â which is the whole point â and that means you will, eventually, ship a page where the button in the header and the button in the body are visually two versions apart, because two teams updated on two different weeks.\n\nAnd orchestration â routing between micro-frontends, sharing auth state, handling the moment one micro-frontend fails to load while the rest of the page is fine â is genuinely hard engineering that a single-app architecture simply doesn't need to solve, because there's only one app.\n\nNone of these costs are disqualifying. They're the price of the thing micro-frontends are actually good at: letting teams ship independently without waiting on each other. The mistake is paying that price for a benefit you didn't need.\n\n## The question that actually decides it\n\nSo here's the test I'd apply before anyone reaches for micro-frontends: **can two teams currently ship features to production without one blocking the other, in the architecture you already have?**\n\nIf the honest answer is yes â one repo, decent ownership boundaries, feature flags, a CI pipeline that doesn't make Team A wait on Team B's review â then you don't have the problem micro-frontends solve. You have a problem that looks similar (big app, multiple teams) but isn't actually causing the pain micro-frontends are priced to fix, and adopting them will hand you the costs above in exchange for nothing.\n\nIf the honest answer is no â teams are routinely blocked on each other's release windows, a shared repo's CI queue has become a real bottleneck, or (the case I'd take most seriously) you've inherited genuinely separate applications from an acquisition or a long history of independent teams and merging them isn't realistic â that's the org chart telling you the org is already split. Micro-frontends, in that case, aren't creating the split. They're the architecture that finally matches a split that already exists in how the company works.\n\nThat's Conway's Law, and I don't think it's a fun aside here â it's the actual decision criterion. Your system's structure is going to mirror your communication structure whether you plan for it or not. Micro-frontends are what you get when you deliberately let that happen instead of fighting it with an org chart your codebase doesn't reflect.\n\n## What I'd actually check first\n\nBefore signing off on a micro-frontends architecture, I'd want to see the org chart, not the bundle analyzer. How many teams actually own frontend code that needs to ship independently? Is the current blocker genuinely architectural, or is it a CI pipeline that could be fixed for a fraction of the cost of a rewrite? And if the teams merged into one tomorrow, would the case for micro-frontends survive that?\n\nIf it wouldn't â if the whole justification evaporates the moment you imagine one team owning all of it â then you weren't looking at an architecture problem. You were looking at a coordination problem, and there's usually a cheaper fix for that than teaching your frontend to run as five separate applications that now have to talk to each other.\n"},{id:"react-playbook-layered-vs-feature-sliced",title:"Layered vs Feature-Sliced: Two Ways to Cut the Same App",excerpt:"Every frontend architecture is really just a decision about which axis you slice the app along â by technical kind, or by feature. Both are correct. Both eventually hurt. The question worth asking isn't which one is better, it's which one is cheaper to be wrong about later.",tags:["React","Architecture","FSD","React Playbook"],date:"Sep 12, 2026",readTime:"10 min read",coverImage:"/blog/react-playbook-layered-vs-feature-sliced/cover.png",published:!0,content:"\nTake any non-trivial React app and you can describe its whole folder structure as the answer to one question: when you cut this codebase into pieces, which axis do you cut along? There are really only two honest answers, and almost every architecture discussion I've ever sat in is secretly an argument about which one a team picked without realizing they'd picked one.\n\n**Layered** cuts by technical kind. All the components live together, all the hooks live together, all the API calls live together, regardless of which feature they belong to.\n\n```\nsrc/\n components/\n hooks/\n api/\n utils/\n pages/\n```\n\n**Feature-sliced** cuts by product concern. Everything a feature needs â its components, its hooks, its API calls â lives together, and it's the *kind* of file that becomes secondary.\n\n```\nsrc/\n features/\n payment-method/\n order-history/\n account-settings/\n```\n\nNeither of these is a mistake. They're both legitimate answers to \"how do I organize this,\" and the reason this keeps being a live debate is that each one is *correct* for a while and then becomes *expensive* in a completely different, entirely predictable way.\n\n> Every architecture is a bet about which kind of future change you're optimizing for. Layered bets on
1stability of kind. Feature-sliced bets on stability of feature boundaries. Neither bet is safe forever.\n\n## What layered actually gives you\n\nLayered architecture is the one nobody has to be taught. A junior developer can guess where a new hook goes â `hooks/` â without reading a single doc. That's not a small thing. It's the lowest-friction structure there is for the first several months of a project, precisely because it doesn't ask anyone to make a judgment call about feature boundaries before those boundaries are even clear yet.\n\nIt's also genuinely good for a certain kind of change: anything that's cross-cutting by nature. If you're auditing every place the app calls the API layer, or refactoring every hook to a new data-fetching pattern, layered structure puts all of that in one folder, and you can see the whole blast radius by opening one directory.\n\nWhere it breaks is exactly where products stop being simple. Once a feature â say, an order-management flow â starts touching a dozen components, several hooks, a couple of API modules, and its own utils, \"layered\" stops meaning \"organized\" and starts meaning \"this feature's logic is now distributed across five folders that also contain twenty other features' logic.\" Want to delete the order-management feature? You're now grepping across the whole codebase, hoping you find every file that only exists because of it. Layered structure doesn't track feature boundaries, so nothing in the folder tree tells you where a feature ends.\n\n## What feature-sliced actually gives you\n\nFeature-sliced structure solves exactly that problem. A feature's folder is a feature's blast radius. Deleting `features/order-history/` deletes the feature â not perfectly, there's usually some shared code left behind, but close. Onboarding someone onto *one* feature means pointing at *one* folder, not explaining the layer system and hoping they can mentally reconstruct which fragments across five layers belong to the thing they're working on.\n\nIt scales with team size in a way layered structure doesn't. Multiple teams, each owning a handful of features, can work with almost no folder-level collision, because they're rarely in each other's slices. That's the real argument for feature-sliced at scale â not \"it's cleaner,\" but \"it lets N teams work without stepping on each other's files.\"\n\nThe cost shows up the moment two features need the same thing. Every feature-sliced codebase eventually accumulates a `shared/` folder, and that folder has exactly the failure mode you'd expect: things get promoted to it reactively, usually the first time a second feature needs something, rarely with much thought about whether it's actually a stable, general-purpose piece or just two features that happen to look similar today. Left unmanaged, `shared/` becomes the layered architecture's `utils/` folder wearing a nicer name â a dumping ground, just one level removed.\n\nAnd there's a subtler cost: feature-sliced structure asks you to correctly guess feature boundaries *before* the product has told you what they are. Early in a project, \"is this one feature or two\" is often genuinely unknowable, and picking wrong means a slice that either merges awkwardly with another later or splits painfully in two.\n\n## The actual decision\n\nI don't think there's a universal right answer here, and I'm suspicious of anyone who gives you one without asking what you're building. The question that actually matters is: **what changes more often in this product â the technical kind of thing, or the feature it belongs to?**\n\nIf you're building something where features are genuinely stable and rarely added (an internal admin tool with five screens that haven't changed in two years), layered structure's simplicity is worth more than feature-sliced's isolation, because you're not paying feature-sliced's up-front cost of guessing boundaries for a product that isn't going to grow new ones.\n\nIf you're building something where the *number* of features is the thing that grows â a product adding new user-facing capabilities every quarter, with multiple teams shipping in parallel â feature-sliced earns its cost almost immediately, because the layered alternative means every new feature adds friction to five existing folders instead of creating one clean new one.\n\nMost real products start in the first category and migrate into the second without anyone deciding to. That's the actual argument for feature-sliced conventions like FSD: not that they're objectively better, but that [the underlying problem they solve](/blog/react-playbook-what-fsd-actually-fixed) â coordination at scale â is the one almost every successful product eventually has, even if it didn't start with it.\n\n## A hybrid is not cheating\n\nThe reasonable middle ground, and the one I actually reach for on real projects, is layered at the top for genuinely global concerns â routing, design tokens, a handful of app-wide providers â and feature-sliced underneath for everything that's actually product surface area.\n\n```\nsrc/\n app/ // routing, providers, global config â layered, rarely touched\n shared/ // genuinely cross-feature primitives, promoted deliberately\n features/\n payment-method/ // feature-sliced from here down\n order-history/\n account-settings/\n```\n\nThe discipline that makes this work isn't the folder names. It's being honest about which things are actually global (rare, and worth naming explicitly) versus which things just look similar between two features today and might diverge next quarter. Promote to `shared/` when a third feature needs something, not when a second one does â that one rule prevents most of the premature abstraction that turns `shared/` into a second `utils/` folder.\n\nPick the axis that matches what actually changes in your product, be honest about which folder is doing that job right now, and don't confuse \"we chose a structure\" with \"we solved organization.\" You didn't solve it. You picked which future problem you'd rather have.\n"},{id:"react-playbook-what-fsd-actually-fixed",title:"What FSD Actually Fixed (and What It Didn't)",excerpt:"Feature-Sliced Design got popular because frontend spent years being treated as the part of the codebase that didn't need real architecture. It fixed that perception. It did n
1ot fix cohesion, and pretending it did is how you end up with a button smeared across ten folders.",tags:["React","Architecture","FSD","React Playbook"],date:"Sep 09, 2026",readTime:"10 min read",coverImage:"/blog/react-playbook-what-fsd-actually-fixed/cover.png",published:!0,content:"\nFor a long time, \"frontend architecture\" was almost a contradiction in terms. Backend had layers, domains, bounded contexts, decades of literature. Frontend had `components/`, `utils/`, and a folder called `helpers` that everyone was quietly afraid to open. The general vibe, even among good engineers, was that the frontend didn't need real architecture â it was just where you rendered the thing the backend actually built.\n\nFeature-Sliced Design showed up into that vacuum, and I think that context matters more than the methodology's specific rules. FSD didn't just propose a folder convention. It made an argument: the frontend is complex enough, and grows unpredictably enough, that it deserves the same seriousness backend systems get. That argument is the actual contribution, and it's the reason FSD spread as fast as it did â not the specific layer names.\n\n> FSD's real achievement wasn't a folder structure. It was convincing an entire ecosystem that the frontend was worth architecting at all.\n\nI've [written before](/blog/smeared-component) about how following FSD's rules literally, without thinking, produced one of the worse codebases I've built â a single button smeared across ten folders, each one \"correctly\" following the layer it was assigned to. That article was about the failure mode. This one is about something narrower: which problem FSD was actually solving, and where the credit for \"clean architecture\" quietly gets misattributed to a folder convention that never promised it.\n\n## What FSD actually fixed\n\nThree things, and they're real.\n\n**It gave teams a shared vocabulary for growth.** Before FSD, \"where does this go\" was answered differently by every senior engineer on the team, usually based on whatever they'd seen at their last job. FSD gives you `pages`, `features`, `entities`, `shared` â not perfect names, but names everyone on a team can agree mean the same thing. That alone kills a huge amount of bikeshedding.\n\n**It made import direction a rule instead of a convention.** The layer hierarchy â shared can't import from entities, entities can't import from features, and so on â is enforceable with a lint rule. That's the part I actually think is underrated. Most frontend codebases don't have architectural violations because someone made a bad call; they have them because nothing stopped the bad call from compiling. FSD turned \"please don't import upward\" from a code review comment into a build failure.\n\n**It gave \"the frontend has architecture\" a name people could put on a job posting.** This sounds cynical, but I mean it as a genuine point: before FSD (and a handful of contemporaries), it was hard to even have the conversation about frontend architecture in a hiring or planning context, because there wasn't a shared reference point. Now there is. That's a real, if unglamorous, win.\n\n## What it never promised to fix\n\nHere's where I think people â including a version of me, a few years back â get it wrong. FSD tells you *where* a piece of code should live relative to other pieces. It does not tell you *whether the pieces you're splitting apart should have been split apart in the first place.*\n\nThat distinction matters because the smeared-component failure isn't a bug in FSD. It's what happens when you use a layering convention to answer a question it was never designed to answer: how much of a single feature's logic should live together versus be distributed. FSD's layers describe *horizontal* concerns â what kind of thing is this (a hook, a type, an API call) â and following them faithfully will happily scatter every piece of a single *vertical* concern (one feature, one component, one story) across the whole hierarchy, because each piece genuinely does belong to a different layer by FSD's own rules.\n\n```\n// \"Correct\" FSD placement for one payment-method component\nsrc/\n features/payment-method/model.ts // the hook and state\n entities/payment-method/types.ts // the type\n shared/hooks/useDebounce.ts // a helper it needs\n shared/api/paymentMethods.ts // the fetch call\n features/analytics/paymentTracking.ts // its analytics event\n```\n\nEvery one of those placements passes an FSD lint rule. None of them help the next engineer who needs to change how the payment method selector behaves, because understanding it now requires reconstructing it from five files across three layers, in a codebase that will tell you, correctly, that nothing is architecturally wrong.\n\nFSD also doesn't fix cohesion at the *page* level, and it doesn't fix team discipline. A team that argues about everything else will still argue about whether a given piece of logic is a `feature` or an `entity` â FSD gives you a vocabulary for the argument, not an answer to it. And a team with no code review discipline will produce a messy FSD codebase exactly as fast as it would have produced a messy flat one; the folders will just be tidier while it happens.\n\n## The credit-misattribution problem\n\nI think this is the actual thing worth naming: team
1s that adopt FSD often experience a real improvement in their codebase, and then attribute all of it to the layering. Some of the improvement is the layering. A meaningful chunk of it is something else entirely â the simple fact that adopting *any* deliberate structure, discussed and agreed on as a team, forces the conversations that were previously being skipped. \"Where does this go\" gets asked out loud instead of guessed at. That conversation is valuable regardless of which methodology prompted it.\n\nWhich means the honest version of \"we adopted FSD and our codebase got better\" is often \"we adopted a shared convention, discussed it as a team, and enforced it with tooling â and FSD happened to be the convention we picked.\" That's still a win. It's just a different, more general win than \"FSD solved our architecture problem,\" and conflating the two is how teams end up surprised when FSD alone doesn't prevent a smeared component, a bloated `shared/` folder, or a `pages/` layer that's turned into a dumping ground.\n\n## What I actually reach for now\n\nI still use FSD's structure as a starting point â [the version I use in practice](/blog/react-playbook-code-structure) keeps the layer names but treats them as a coarse sort, not a mandate to split every feature into
1its smallest constituent parts. The rule I actually enforce is closer to: a feature's pieces get distributed across layers only when another feature genuinely needs to share them. Until then, they stay together, even if that means a `features/payment-method/` folder that internally holds its hook, its types, and its API call in one place, technically \"violating\" the purist reading of the layer boundaries.\n\nThat's not a rejection of FSD. It's treating it as what it actually is: a good answer to \"how do we agree on where things go as this project grows,\" and a tool that says nothing useful about \"how much should stay together.\" Knowing which question you're asking is the part that doesn't come in the docs.\n\nIf your team adopted FSD and it didn't fix the thing you hoped it would, I'd genuinely ask which of the two problems you actually had â because they need different fixes, and only one of them is solved by a folder structure.\n"},{id:"checkout-architecture-time-to-goal",title:"Checkout Isn't a Form. It's the Only Part of the App Where Architecture Has a Stopwatch on It",excerpt:"Most of an app can afford to be a little slow, a little clunky, and still get forgiven. Checkout can't. What you're actually selling in that flow isn't a UI â it's time-to-goal. Every architectural choice in there either protects that number or quietly taxes it.",tags:["E-commerce","Checkout","Architecture","UX","Case Study"],date:"Sep 06, 2026",readTime:"11 min read",coverImage:"/blog/checkout-architecture-time-to-goal/cover.png",published:!0,content:"\nI've spent a real chunk of my career around purchase flows â one-click purchase, multi-step checkout, the whole family of screens between \"I want this\" and \"I bought this.\" And the thing that took me the longest to internalize wasn't a pattern or a library. It was what we're actually selling in that flow.\n\nIt isn't the UI. It isn't even the product, at that point â the user already decided on the product two screens ago. What we're selling is *time-to-goal*. How fast, with how little friction, does this person get from \"I'm ready to buy\" to \"it's done\"? Every metric that actually matters downstream â conversion, cart abandonment, repeat purchases â is a proxy for that one number.\n\n> The product in a checkout flow isn't the form. It's the distance between intent and confirmation.\n\nThis sounds like a UX statement, and it is one. But I want to argue it's also, maybe primarily, an *architecture* statement â because the UX-y stuff people file under \"polish\" (responsiveness, one-click purchase, graceful recovery from a dropped connection) is downstream of decisions made in how the checkout is built, not decisions made in Figma.\n\n## Every step is round-trip debt\n\nHere's the mental model I use. Every screen, every confirmation, every \"are you sure\" in a checkout flow is a withdrawal against the user's patience. Doesn't matter how pretty it is. A step is a step.\n\nMulti-step checkouts get justified all the time â shipping, then payment, then review â and sometimes that's genuinely the right shape. But I'd push back on treating that as free. Each transition is a network round trip if it's fetching anything, a re-render if it isn't, and either way it's a moment where the user can get pulled away, get a spinner they don't trust, or hit the back button and lose state they already entered.\n\nThe architectural question isn't \"how do we make step 2 nice.\" It's \"does step 2 need to exist as a separate step at all, or is it three fields we could've asked for on step 1 without anyone noticing the difference.\" That's a product conversation, sure, but it's the frontend architect's job to keep asking it, because engineers are the ones who feel the cost of *not* asking it â in state management, in the number of places cart data can go stale, in every edge case around \"what if they refresh here.\"\n\n## One-click purchase is a state-management problem wearing a UX costume\n\nOne-click purchase looks, from a design brief, like \"remove the button presses.\" Architecturally, it's a much harder problem: you're compressing an entire multi-step flow's worth of validation, payment, and inventory checks into a single optimistic action, and
1you have to do it without ever showing the user a screen that says \"wait.\"\n\nThat means the hard part isn't the click. It's everything that has to already be true *before* the click for the click to be safe:\n\n```ts\ninterface OneClickEligibility {\n hasValidPaymentMethod: boolean;\n hasCompleteShippingAddress: boolean;\n itemInStock: boolean;\n priceUnchangedSinceLastSync: boolean;\n}\n\nfunction canOneClickPurchase(state: OneClickEligibility): boolean {\n return (\n state.hasValidPaymentMethod &&\n state.hasCompleteShippingAddress &&\n state.itemInStock &&\n state.priceUnchangedSinceLastSync\n );\n}\n```\n\nEverything hinges on that check being *current* at the moment of the click, not current as of when the page loaded. If price or stock data is stale by even a few seconds, \"one click\" turns into \"one click, then a screen apologizing that the price changed\" â which is worse than a normal checkout, because you promised speed and delivered a rug-pull.\n\nSo the real architectural commitment behind one-click purchase isn't a button component. It's a decision to keep a small slice of critical state (price, stock, payment validity) continuously fresh in the background, so the click can be trusted the instant it happens. That's a background-sync and cache-invalidation problem, not a UI problem, and it needs to be treated as first-class â not bolted on after the button ships.\n\n## Optimistic UI, but with a real rollback story\n\nCheckout is one of the few places where optimistic UI actually earns its complexity, because the alternative â making someone stare at a spinner while you confirm a card charge â is its own kind of failure. But optimistic UI without a serious rollback path is just a faster way to lie to the user.\n\n```tsx\nfunction useSubmitOrder() {\n const [status, setStatus] = useState<'idle' | 'confirming' | 'error'>('idle');\n\n const submitOrder = async (order: OrderDraft) => {\n setStatus('confirming'); // show success-leaning state immediately\n\n try {\n const confirmed = await placeOrder(order);\n return confirmed;\n } catch (error) {\n setStatus('error');\n // the user already saw a hopeful state â the recovery message\n // has to explain what changed, not just that something failed\n throw error;\n }\n };\n\n return { status, submitOrder };\n}\n```\n\nThe detail that actually matters here is the comment. If you show a confident \"confirming your order\" state and then it fails, the failure message can't be a generic toast. The user's mental model has already moved forward â they think they're done. Rolling that back cleanly, with a message that says what actually happened (card declined vs. item went out of stock vs. network timeout), is architecture work: it means your error states need to carry *why*, not just *that*, all the way from the API layer up.\n\n## Responsiveness is a checkout feature, not a checkout nice-to-have\n\nI'd draw a hard line here: on most of an app, a layout that reflows awkwardly on a weird viewport is a bug you triage next sprint. On checkout, it's a lost sale, because a meaningful chunk of purchases happen one-handed, on a phone, possibly in a moving vehicle, possibly with one bar of signal.\n\nThat changes the priority order of what you architect for. Payment fields need to work with autofill without fighting the browser. Buttons need to be large enough that a shaky hand doesn't mis-tap into \"cancel.\" And the layout needs to survive the keyboard eating half the screen on mobile, because \"the confirm button is off-screen when the keyboard is open\" is a shockingly common way to lose an order that was otherwise complete.\n\nNone of that is exotic. It's just a different priority order than the rest of the app gets, and if your component architecture treats checkout screens as \"just more pages,\" they'll get the same generic responsive treatment as everything else â which is to say, good enough for a blog post, not good enough for a payment form.\n\n## The metric that should be driving these decisions\n\nHere's the part I actually want to land, because it's the thing that reframes all of the above from \"best practices\" into an actual measurement problem.\n\nIf the product is time-to-goal, then the thing worth instrumenting isn't page views or even conversion rate in isolation â it's the *distribution* of time between \"entered checkout\" and \"confirmed order,\" broken down by step, and the drop-off at each step. That's a genuinely different question than \"does this page load fast,\" and it points architecture in a specific direction: every step needs a timestamp, every abandonment needs a last-known-step, and every retry needs to be attributable to a cause (validation error, payment decline, network failure, user just left).\n\n```ts\nfunction trackCheckoutStep(step: CheckoutStep, elapsedMs: number, outcome: 'advanced' | 'abandoned' | 'errored') {\n analytics.track('checkout_step', { step, elapsedMs, outcome });\n}\n```\n\nThat's a small function. The architectural commitment behind it â instrumenting every step consistently, from the same source of truth as the state machine driving the flow, not bolted on separately by whoever remembers to add tracking â is the actual work. Get that right and you have a real answer to \"where is friction actually happening,\" instead of a guess dressed up as a redesign.\n\n## Where this leaves the architecture conversation\n\nNone of this is about picking the right library for multi-step forms. It's about recognizing that checkout is the one part of the app where the business metric (completed purchase) and the architecture metric (time-to-goal, state consistency across steps, recovery from failure) are the same number wearing two names. Everywhere else in the app, you can separate \"is this well-architected\" from \"does this convert.\" In checkout, you mostly can't â a badly architected checkout *is* a checkout with worse conversion, because every extra round trip, every stale price, every optimistic update with no honest rollback is friction the user feels as delay between wanting the thing and having it.\n\nIf you're the one arguing for a cleaner checkout architecture and getting pushback that it's \"just implementation detail\" â it isn't. I'd make the case that of everything in the app, this is the part where the architecture review and the conversion review should be the same meeting.\n\nI've got a lot more from this part of my career than fits in one article â one-click purchase edge cases, retry strategies for flaky payment providers, the exact way stale cart state causes support tickets. If there's a piece of this you want me to go deeper on, tell me which one and I'll write it.\n"},{id:"react-playbook-types-that-earn-their-keep",title:"Most Advanced TypeScript Isn't Worth What It Costs",excerpt:"There's a point where a type stops protecting you and starts being a puzzle you have to solve again every time you touch it. I'm not a type wizard and I've stopped trying to be one â because on real React codebase
1s, the complexity that pays for itself is a much shorter list than the internet suggests.",tags:["React","TypeScript","Architecture","React Playbook"],date:"Sep 03, 2026",readTime:"10 min read",coverImage:"/blog/react-playbook-types-that-earn-their-keep/cover.png",published:!0,content:"\nLet me start with an admission, because it shapes everything below: I'm not especially strong at advanced TypeScript. Conditional types nested three deep, recursive template literal parsers, the kind of thing that shows up in a library's internals and gets applauded on Twitter â I read that code slowly, and I don't enjoy writing it.\n\nFor a while I treated that as a gap to close. Now I mostly treat it as a constraint worth designing around, and I think that's the more useful position. Not because type-level programming isn't impressive. Because on an application codebase, a type that only one person on the team can modify is a liability wearing the costume of rigor.\n\n[The earlier TypeScript article](/blog/react-playbook-typescript-patterns) covered the patterns I reach for constantly â typing the API boundary, discriminated unions for async state, inference at the router. This one is about the question that comes *after* you know the patterns: how much type complexity should a given piece of code carry, and how do you tell when you've overshot?\n\n## Types have a running cost\n\nThe pitch for Type
1Script is that types catch bugs. True, and I'd never go back. But the framing hides something: a type isn't a one-time purchase. It's a thing every future reader has to understand before they can safely change the code underneath it.\n\nSo each type has a cost, and it shows up in three places. Someone has to read it to understand the contract. Someone has to modify it when requirements move. And when it goes wrong, someone has to decode the error message â which, for a sufficiently clever type, is a wall of text that names none of the things you actually wrote.\n\nThat last one deserves more weight than it usually gets. A type that produces an unreadable error at the call site has partly defeated its own purpose. The whole point was to tell someone they made a mistake. If the message needs its own investigation, you haven't caught the bug so much as relocated it.\n\n> The value of a type is not how much it proves. It's how much it proves *per unit of understanding it demands*.\n\nThat ratio is the thing I actually optimize for now, and it explains why my list of \"worth it\" is short.\n\n## What earns its keep\n\n**Discriminated unions, everywhere.** This is the highest-value construct in application TypeScript and it's barely \"advanced\" at all:\n\n```ts\ntype PolicyRequest =\n | { status: 'idle' }\n | { status: 'loading' }\n | { status: 'error'; message: string }\n | { status: 'success'; policies: Policy[] };\n```\n\nFour lines, and an entire class of bug is now unrepresentable â you cannot read `policies` without first proving you're in the success branch. The compiler narrows it for you, the errors are legible, and any developer can extend it without a conversation. Cheap to read, expensive bugs prevented. That's the ratio you want.\n\n**Branded types, but only where mixups are real.** If your codebase passes around several kinds of ID as bare strings and you've actually shipped a bug from swapping two of them, this is worth it:\n\n```ts\ntype PolicyId = string & { readonly __brand: 'PolicyId' };\ntype CustomerId = string & { readonly __brand: 'CustomerId' };\n```\n\nNote the condition. *If you've actually shipped that bug.* Branding every primitive in the app because it's theoretically safer is how you end up with casts scattered everywhere and a team quietly annoyed at you.\n\n**`satisfies` instead of annotation, when you want both.** This one is underused and it's almost free:\n\n```ts\nconst routePermissions = {\n policies: ['read', 'write'],\n billing: ['read'],\n} satisfies Record<string, Permission[]>;\n```\n\nYou get the constraint checked *and* the literal types preserved, so `routePermissions.billing` is `['read']` rather than a widened `Permission[]`. No cleverness required, real inference gained.\n\n**Generics on components, one parameter deep.** A typed `DataTable<TRow>` that infers the row type from the data you pass it is genuinely good. Two or three interdependent type parameters with constraints referencing each other is where it stops paying, in my experience â the signature becomes the thing people copy-paste rather than read.\n\n## What usually doesn't\n\n**Type-level logic that reimplements runtime logic.** Deep conditional types that compute a shape based on five flags. Recursive types that parse a string format into a structure. These are legitimately impressive and they belong in libraries, where a small number of maintainers absorb the complexity so thousands of users don't have to. In application code the arithmetic is inverted: your whole team pays, nobody outside benefits, and the requirements will change next quarter anyway.\n\n**Types that exist to avoid a small runtime check.** Sometimes the honest answer is a validation at the boundary and a plain type afterward:\n\n```ts\nconst policy = policySchema.parse(response.data);\n```\n\nYou've now got a real guarantee â checked against actual data, not just asserted about it â and the type downstream is boring. I'd take that over an elaborate type that describes what the server *should* send, every time. The type system can't see your API. A parser can.\n\n**Generic abstractions built for one call site.** The rule I'd apply here is the same one that applies to component abstractions: don't generalize until you have the second case in front of you. A generic hook with three type parameters serving exactly one consumer is a puzzle you built for yourself.\n\n## The test I actually use\n\nWhen I'm not sure whether a type has gone too far, I ask two things.\n\nFirst: *can a mid-level developer on this team change the code this type guards, without asking me?* If the honest answer is no, the type has become a bottleneck with my name on it. That's not safety â it's a bus factor problem I introduced on purpose.\n\nSecond: *what does the error look like when someone gets it wrong?* I'll take a slightly weaker type with a clear failure message over a stronger one that produces forty lines of inference noise. The error message is the user interface of a type, and it deserves the same consideration as any other interface.\n\nNeither test involves how much the type proves. That's deliberate.\n\n## Where I've landed\n\nI used to think being weak at type-level programming meant I'd eventually hit a ceiling in TypeScript. What actually happened is that the ceiling turned out to be somewhere I don't need to go very often â and that most of the safety I care about comes from a handful of unglamorous constructs applied consistently at the right places, mostly at the edges where data enter
1s the app.\n\nThe interesting decisions aren't in the type system at all. They're about *where* you put the boundary, what you validate, and which mistakes you've decided are worth making impossible. Once those are right, the types tend to stay simple on their own. When I find myself writing something genuinely gnarly, it's usually a sign the design underneath it is doing something it shouldn't.\n\nI'm aware this is the position of someone who isn't a type wizard, and I'd take the counter-argument seriously. So if you've got a case where deep type-level machinery genuinely paid for itself in an application â not a library â I want to see it. That's the example that would move me.\n"},{id:"agent-playbook-instructions-rot",title:"Your Agent Instructions Are Rotting Right Now",excerpt:"Nobody writes agent rules once and walks away â they write them, then keep adding. A line here after a bad run, a paragraph there after a near-miss. Six months later the file contradicts itself, and the agent is following the version of your project that stopped existing in March.",tags:["AI Agents","Architecture","The Agent Playbook"],date:"Sep 01, 2026",readTime:"9 min read",coverImage:"/blog/agent-playbook-instructions-rot/cover.png",published:!0,content:"\nThere's a particular kind of file that only ever grows. You know the one. It started as a short list of project conventions for your agent, and every time something went sideways you appended a line to stop it happening again. *Always run the build before committing. Don't touch the lockfile. Use the existing helper instead of writing a new one.*\n\nEvery line was correct on the day it was written. That's what makes this hard to see.\n\nBecause instructions aren't code. Nothing fails when they go stale. There's no red test, no type error, no failing pipeline. A rule that stopped being true six months ago sits there looking exactly like a rule that's still true, and the agent â being obedient â follows both with equal conviction.\n\n> Instructions don't break loudly. They just quietly stop describing your project.\n\n[The context is the product](/blog/agent-playbook-context-is-the-product) made the case that what you feed an agent matters more than which model you picked. This is the uncomfortable follow-up: that context is an artifact you own, and artifacts you own need maintenance. Nobody budgets for maintaining a text file.\n\n## How the rot actually happens\n\nIt's never one dramatic mistake. It's four small, reasonable things.\n\n**The project moves and the file doesn't.** You migrate from one test runner to another, rename a directory, drop a library. The code changes in one commit. The instruction describing the old world changes in... no commit, because nobody thought about it. Now the agent is confidently steering toward a folder that isn't there.\n\n**Rules get added to fix single incidents.** The agent did something dumb once, so you wrote a rule. The rule is narrow, situational, and phrased as a universal law. Twenty of those later, you've encoded a list of past accidents rather than a description of how the project works.\n\n**Nothing ever gets deleted.** Deleting a rule feels risky â what if it was load-bearing? So the file only accretes. And because instructions usually sit near the top of the agent's context, every dead line is taking up room that live information could be using.\n\n**Contradictions arrive silently.** Rule 4 says prefer the shared utility. Rule 19, added months later by someone solving a different problem, says write it locally in the feature folder. Both are in the file. The agent picks one, more or less arbitrarily, and now its behavior looks random â which is the symptom people usually misdiagnose as the model being unreliable.\n\nThat last one is worth sitting with. When an agent behaves inconsistently across similar tasks, the instinct is to blame the model or add *another* rule. Often the real cause is that you've given it two rules and asked it to guess.\n\n## The symptoms, before you go looking\n\nYou can usually feel this before you can point at it.\n\nThe agent starts doing things you don't remember asking for, and when you grep the instructions, there it is â a line from months ago you'd completely forgotten writing. Or it does the right thing four times out of five, and the fifth is a coin flip. Or you find yourself correcting the same class of thing in review over and over, adding a clarification each time, and the clarifications aren't helping. That's the tell: if adding rules isn't improving behavior, the problem isn't a missing rule. It's the pile.\n\nHonestly, the strongest signal is simpler. When was the last time you read your instructions file top to bottom? If you can't remember, you're not maintaining a document, you're maintaining a sediment layer.\n\n## Treat it like code, be
1cause it is\n\nThe fix isn't clever. It's just deciding that this file has an owner and a lifecycle, the same as any other part of the system.\n\n**Read it end to end on a schedule.** Not when something breaks â on a cadence. It takes ten minutes. You will find at least one line that describes a project that no longer exists.\n\n**Delete aggressively.** This is the part people won't do, so let me put it plainly: a rule you can't justify today is doing damage today. It's consuming context, and it's a coin-flip waiting to happen when it eventually contradicts something newer. If you're wrong and it mattered, you'll find out fast and can put it back in thirty seconds. That's a cheap mistake. Carrying twenty dead rules is not.\n\n**Prefer describing the system over listing incidents.** \"Feature code lives under `src/features/{feature}`, and anything shared moves to `src/shared` only when a second feature needs it\" is worth ten rules about specific files. Principles survive refactors. Incident notes don't.\n\n**Hunt contradictions directly.** When you review, don't just ask *is this line true?* Ask *does this line disagree with another line?* Contradictions are the expensive failure here, and they're invisible unless you're specifically looking.\n\n**Update instructions in the same commit as the change.** You renamed the folder â the rule mentioning that folder is part of the rename. This is the one habit that stops rot at the source, and it costs nothing once it's automatic.\n\n## I've had to redo this myself\n\nI'm not writing this from the outside. The memory system on this site is exactly this problem in a different shape â a set of files an agent reads before it does anything, describing conventions and decisions.\n\nI wrote up the first version, published it, and then [redesigned the whole thing after a few weeks of actually using it](/blog/ai-agent-memory-redesign). Not because the original idea was wrong, but because the first design let information accumulate without ever forcing anything out. The rules that ended up mattering were about *overwriting* rather than appending: this file gets rewritten in full, that entry gets edited in place rather than duplicated, completed items get deleted rather than archived. Not because deletion is elegant. Because anything that only grows eventually stops being read â by me, and by the agent.\n\nThat's the lesson I'd extract for instructions generally. The interesting design question isn't what to write down. It's what forces something to leave.\n\n## Small is a feature\n\nThere's a version of this article that ends with \"so audit your rules regularly,\" and that's true but weak. The stronger claim is that the size of an instruction file is itself a quality signal, in the wrong direction.\n\nA short, current, internally consistent set of rules beats a comprehensive one, every time. The agent reads all of it. You can hold all of it in your head. When something's wrong you can find it. A long file fails on all three, and it fails invisibly, which is the worst property a piece of configuration can have.\n\nSo the question I'd ask about your instructions isn't whether they're thorough. It's whether you'd be comfortable reading them out loud to a new engineer joining tomorrow â and how many lines you'd catch yourself apologizing for on the way through.\n\nIf you've found a maintenance rhythm that actually sticks â something better than \"review it when the agent embarrasses you\" â I'd like to hear it. That's the part I'm still refining.\n"},{id:"the-slider-interview",title:"They Asked Me to Build a Slider. It Was a Much Better Question Than I Thought.",excerpt:"A carousel with next, previous, and a row of dots. No animation. I remember thinking: is this it? Then I watched my own first thirty seconds of typing and realized the task wasn't measuring whether I could build a slider. It was measuring the order I do things in.",tags:["Interviewing","Career","React","Engineering Culture"],date:"Aug 30, 2026",readTime:"10 min read",coverImage:"/blog/the-slider-interview/cover.png",published:!0,content:"\nI sat down for a technical interview recently and got handed this: here's a React page, here are ten image URLs from the internet, build a slider. Next button, previous button, a row of dots underneath. Click a dot, jump straight to that slide. No animation needed.\n\nMy honest first reaction was *is this it?* I'm an architect. I'd come in expecting to defend some structural decision, argue about boundaries, maybe get grilled on a system design. Instead I got a task I'd have called kindergarten-level on paper.\n\nThen I started typing, and about thirty seconds
1in I got genuinely interested â not in the slider, in what the slider was doing to me.\n\n## The first thirty seconds\n\nBefore writing anything I asked questions. Do you want keyboard support? Should it wrap around? Do the images need preloading? Some of those got a *no, don't worry about it*. Fine â that's an answer, and now the scope is pinned down instead of assumed.\n\nThen I opened the component and the first thing I wrote was state:\n\n```tsx\nconst [currentSlide, setCurrentSlide] = useState(0);\n```\n\nOne value. That's all this thing needs to know about itself â which slide is showing. Everything else is derived.\n\nAnd the second thing I wrote was three empty functions:\n\n```tsx\nconst handleNext = () => {};\nconst handlePrevious = () => {};\nconst handleSelectSlide = (index: number) => {};\n```\n\nEmpty. Nothing inside them. I hadn't decided the boundary behavior yet, hadn't written a single `div`. I just named the three things this component can *do*, and then kept going.\n\nI want to be careful here, because this is the part I actually find interesting and it would be easy to oversell. I'm not claiming that's the One True Order. But sitting there, watching myself do it, I realized I had described the entire component â its state and its complete behavior surface â before rendering anything. The markup afterwards was almost mechanical. There was nothing left to decide.\n\n> The interesting part of a trivial task isn't whether you finish it. It's what you reach for first.\n\n## The same task, three ways\n\nI've never sat on the other side of the table scoring people on this, so take what follows as a hypothesis rather than a verdict. But I think this task separates approaches cleanly, and the separation has nothing to do with knowing React.\n\n**Start from the markup.** Drop in a `div`, put an `img` in it, add two buttons. Then wire up the next button â oh, that needs a handler, scroll back up, write one. That needs state â scroll up again, add `useState`. Then the dots, which need to know the active index, which means going back to think about what the index actually means. The code arrives, but it arrives by accretion, each piece pulling the previous one apart a little.\n\n**Start from the pieces, one at a time.** State first, then a handler, then the markup for that handler, then the next handler. Better â but each behavior is decided in isolation, so the boundary rules (\"what happens at the last slide?\") get discovered one at a time, in the middle of writing JSX, when you're least set up to think about them consistently.\n\n**Start from state and behavior as a set.** One `useState`, three named handlers, all declared before anything renders. The boundary question surfaces immediately and gets answered once, for all three, because they're sitting right next to each other. Then you render.\n\nThe difference isn't skill with React. All three people finish. The difference is whether the shape of the component was decided up front or discovered by bumping into it, and *that* is the thing this dumb little task exposes with surprising precision.\n\n## Then they made it worse, and that's when it got good\n\nPart two: show three images per page instead of one. And add looping â from the last page, next goes to the first; from the first, previous goes to the last.\n\nThat sounds like a small tweak. It isn't, and this is the bit I enjoyed.\n\nIn the one-image version, the natural move at the end is to disable the button:\n\n```tsx\n<button onClick={handleNext} disabled={currentSlide === images.length - 1}>\n Next\n</button>\n```\n\nThe boundary is handled in the *markup*, as a disabled state. Now change the rules. With three-per-page and wrapping, \"the end\" isn't the last image anymore, it's the last *page* â and there's no such thing as the end, because it wraps. The button is never disabled. The logic moves out of the JSX and into the handler:\n\n```tsx\nconst pageCount = Math.ceil(images.length / imagesPerPage);\n\nconst handleNext = () => {\n setCurrentPage((page) => (page + 1) % pageCount);\n};\n\nconst handlePrevious = () => {\n setCurrentPage((page) => (page - 1 + pageCount) % pageCount);\n};\n```\n\nTwo things changed underneath, and neither is visible in the requirement as stated. The unit of state stopped being *which image* and became *which page*, which is a different c
1oncept that happens to look the same on screen with ten images and one per page. And the boundary rule migrated from a rendering concern to a behavioral one.\n\nIf your first version had the wrapping logic smeared across the JSX, this is where you pay. If it was already sitting inside three named handlers, you edit two lines.\n\nTen images, three per page, and suddenly the task has an actual opinion about your first draft.\n\n## Say it out loud or none of this counts\n\nHere's the thing that makes the whole exercise work, and it's not the code.\n\nNobody watching can see you reason. They see a cursor. So I narrated the entire time: *I'll start with state â I think all I need to store is which slide is selected. Now the handlers. I'm not going to build arrow-key navigation unless you want it, just next and previous buttons.* And the answer came back: yeah, that's fine, don't bother.\n\nThat exchange took four seconds and it removed a chunk of work I would otherwise have guessed at. More importantly, it made my reasoning visible. Someone watching me could tell *why* the code was landing in that order, which is information they cannot get any other way.\n\nThis is the same argument I made in [the piece on interviewing senior engineers](/blog/interviewing-senior-engineers) from the interviewer's side: the signal is in the reasoning, not the artifact. Sitting on the candidate side of it, I'd put it even more bluntly. If you think silently and produce correct code, you've handed over the least interesting half of what you know.\n\n## And yes, I used Google\n\nAt one point I needed to iterate a fixed number of times to render the dots. The simplest thing is:\n\n```tsx\n{Array(pageCount).fill(0).map((_, index) => (\n <Dot key={index} active={index === currentPage} onClick={() => handleSelectSlide(index)} />\n))}\n```\n\nAnd I blanked on it. Couldn't remember how to make an empty array of a given length. So I asked whether I could look it up, they said sure, I looked it up, and moved on.\n\nI'd like that to be unremarkable, because it should be. Forgetting a piece of syntax you type maybe twice a year says nothing about anything. What would actually have mattered is if I hadn't known *what I was reaching for* â that I needed a fixed-length iteration, that the dots derive from page count rather than being their own state. That part I never doubted. The incantation to produce the array is a lookup.\n\nThe failure mode isn't forgetting an API. It's not knowing what shape of thing you need.\n\n## What I took from it\n\nI don't know how the interview went. Genuinely â no result yet, and I'm writing this without one.\n\nBut I came away thinking the task was much smarter than it looked, and I'd been slightly snobbish about it in the first minute. A hard problem mostly tells you whether someone has seen that problem before. A trivial problem, watched closely, tells you the order someone's mind moves in â and then a small twist tells you whether their first draft was structured or just correct.\n\nThe slider was never the point. It was a transparent object they could watch me think through, and it cost them ten minutes to set up.\n\nIf you're interviewing people and reaching for something elaborate, I'd at least consider the opposite: take something almost insultingly simple, watch the first thirty seconds, then change one requirement. And if you've been on the receiving end of a task like this and read it as disrespect â I did too, briefly. I'd like to hear whether you changed your mind the way I did.\n"},{id:"react-playbook-abort-controller",title:"React Gives You the Loading State. It Does Not Give You Cancellation.",excerpt:"React 19 hands you pending state for free, and that's genuinely nice. But a finished spinner is not a finished request. AbortController is the piece almost nobody reaches for â not because it's hard, but because it looks scarier than it is. Here's the small, boring version that covers most of what you need.",tags:["React","Error Handling","AbortController","Async","React Playbook"],date:"Aug 28, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-abort-controller/cover.png",published:!0,content:"\nThe [error handling article](/blog/react-playbook-error-handling) argued that failures are a product decision â what breaks, how loudly, and what the user does next. There's one failure mode it didn't cover, and it's the one that produces the weirdest bug reports. Not \"the request failed.\" The opposite: the request *succeeded*, just too late, and nobody wanted the answer anymore.\n\nReact 19 made the front half of this much nicer. `useActionState` gives you the pending flag without a single `useState`:\n\n```tsx\nconst [result, submitSearch, isPending]
1= useActionState(searchPoliciesAction, null);\n```\n\nThree values, one line, and the loading state is handled. That used to be four lines of ceremony and at least one bug where `setLoading(false)` didn't run on the error path. Real improvement.\n\nBut look at what `isPending` actually tells you. It says React is still waiting on *this* action. It says nothing about the fetch you fired ninety milliseconds ago that's still in flight somewhere over the Atlantic. React flipped a boolean. The network doesn't know that happened.\n\n> A spinner that stops is a UI event. A request that stops is a network event. React only does the first one.\n\n## The bug that survives `isPending`\n\nType into a search box. Every keystroke fires a request. Responses come back whenever they feel like it â the one for \"poli\" leaves first, the one for \"policy\" leaves second and returns first, then \"poli\" lands and overwrites it. Now the input says *policy* and the list below shows results for *poli*.\n\nThe UI is not loading. The UI is not erroring. The UI is confidently wrong, and it stays wrong until the user touches something.\n\nThis is a race, and pending state cannot fix a race. `isPending` was true, then false, exactly as designed. What you needed was for the earlier request to *stop existing* the moment it stopped mattering. That's the job AbortController does, and it's the reason I think it deserves a chapter of its own rather than a footnote in a data-fetching post.\n\nHonestly, I think the main reason this stays exotic is the name. `AbortController` sounds like something you'd find in an operating systems textbook. The actual API is three things.\n\n## The whole API, in about ten lines\n\n```ts\nconst controller = new AbortController();\n\nfetch('/api/policies', { signal: controller.signal });\n\ncontroller.abort();\n```\n\nThat's it. You make a controller, you hand its `signal` to whatever is doing the async work, and calling `abort()` tells that work to give up. The pending `fetch` rejects immediately.\n\nTwo details worth knowing up front, because they're where people trip.\n\nFirst, a signal is single-use. Once a controller is aborted, it stays aborted forever â you don't reset it, you make a new one. One controller per request, not one per component.\n\nSecond, aborting rejects the promise. It doesn't quietly resolve to nothing. Which brings us to the mistake I'd bet is the single most common one in this whole area.\n\n## Cancellation is not a failure\n\nWhen you abort a fetch, it throws. If you have a normal `try/catch` around it, that `catch` runs, and unless you say otherwise the user gets an error state for something *you* deliberately caused.\n\nI've seen this produce red toasts on perfectly healthy apps. Navigate away from a page mid-load, and the app cheerfully informs you that something went wrong. Nothing went wrong. You left.\n\nSo the rule that matters more than any other in this article:\n\n```ts\ntry {\n const response = await fetch(url, { signal });\n return await response.json();\n} catch (error) {\n if (error instanceof DOMException && error.name === 'AbortError') {\n return; // we cancelled this on purpose â not a user-facing failure\n }\n throw error;\n}\n```\n\nAn abort is an instruction you gave. It should never reach your error UI, your toast system, or Sentry. Treat it as control flow, not as a fault.\n\nThere's a newer, cleaner way to express the same check if you already have the signal at hand:\n\n```ts\nif (signal.aborted) return;\n```\n\nSame idea, less `instanceof` archaeology. Both are fine. What's not fine is letting the abort fall through into the same branch as a 500.\n\n## The small version that covers most cases\n\nHere's the shape I'd reach for by default. A ref holding the current controller, aborted right before the next request starts:\n\n```tsx\nfunction PolicySearchPanel() {\n const inFlight = useRef<AbortController | null>(null);\n\n const [policies, searchPolicies, isPending] = useActionState(\n async (_previous: Policy[] | null, formData: FormData) => {\n inFlight.current?.abort();\n const controller = new AbortController();\n inFlight.current = controller;\n\n const query = String(formData.get('query') ?? '');\n const response = await fetch(\n '/api/policies?query=' + encodeURIComponent(query),\n { signal: controller.signal },\n );\n\n return (await response.json()) as Policy[];\n },\n null,\n );\n\n useEffect(() => () => inFlight.current?.abort(), []);\n\n return (\n <form action={searchPolicies}>\n <input name=\"query\" />\n <button type=\"submit\" disabled={isPending}>Search</button>\n </form>\n );\n}\n```\n\nTwo lines do the real work. `inFlight.current?.abort()` kills the previous request before starting a new one, so the stale response can never land. The `useEffect` cleanup aborts on unmount, so navigating away doesn't leave a request writing into a component that no longer exists.\n\nThat's the universal, boring version. It isn't clever and it doesn't need to be. Most cancellation bugs I can think of are covered by exactly those two moments: *something newer started*, and *the thing that wanted this is gone*.\n\nNotice what `useActionState` is and isn't doing here. It owns `isPending` and the result value, which is genuinely less code than before. The cancellation is still entirely yours. The new hooks didn't make this obsolete â they made the missing half more visible, because now the only thing you're hand-rolling *is* the abort.\n\n## The signal has to reach the bottom\n\nHere's where this stops being a component concern and becomes an architecture one.\n\nMost apps don't call `fetch` in components. They call `policiesApi.search(query)`, which calls a shared `httpClient`, which eventually calls `fetch`. If any layer in that chain doesn't take a signal, cancellation dies there â and you'll be sitting in the component wondering why abort does nothing.\n\nSo the API layer needs to pass it through, all the way down:\n\n```ts\nexport async function searchPolicies(\n query: string,\n options?: { signal?: AbortSignal },\n): Promise<Policy[]> {\n const response = await httpClient.get(\n '/api/policies?query=' + encodeURIComponent(query),\n { signal: options?.signal },\n );\n return response.data;\n}\n```\n\nAn optional `signal` on every async function that touches the network. It costs nothing at the call sites that don't care, and it's the difference between cancellation being available and being theoretically available.\n\nThis is also why I'd rather teach the primitive than the library helper. TanStack Query already hands your query function a signal â you just have to *use* it:\n\n```ts\nuseQuery({\n queryKey: ['policies', query],\n queryFn: ({ signal }) => searchPolicies(query, { signal }),\n});\n```\n\nThat signal is there in every TanStack Query app in the world, and a lot of them pass it nowhere. The library already solved the plumbing. If your own layers drop the signal on the floor, the plumbing has nothing to plug into.\n\n## Two extras that are worth knowing\n\nTimeouts, without the `setTimeout` dance:\n\n```ts\nfetch(url, { signal: AbortSignal.timeout(8000) });\n```\n\nAnd combining reasons to stop â say, \"cancel if the user navigates away *or* if eight seconds pass\":\n\n```ts\nconst signal = AbortSignal.any([controller.signal, AbortSignal.timeout(8000)]);\n```\n\nA timeout abort surfaces as a `TimeoutError` rather than an `AbortError`, which is exactly what you want: one of those deserves a message to the user, the other doesn't. That distinction is the whole game â *why* did this stop, and does the person staring at the screen need to know?\n\n## What to actually remember\n\nStrip it down and there are maybe five things:\n\n- One controller per request. Never reuse an aborted one.\n- Abort the previous request before starting the next one.\n- Abort on unmount.\n- Never show an abort in the UI. It's control flow, not an error.\n- Accept an optional `signal` in every async function that reaches the network, or the chain breaks.\n\nNothing there is advanced. It's five habits, and once they're muscle memory you stop writing an entire category of bug â the stale-write, the ghost update, the error toast on a page you already left.\n\n## The part I keep coming back to\n\nWhat strikes me about React 19's async story is how much it improved the *visible* half of the problem. Pending state used to be where the boilerplate lived, and now it mostly isn't. That's real, and I'd take it every time.\n\nBut it makes the asymmetry sharper. The framework got better at telling you it's waiting, and no better at stopping the thing it's waiting for â because it can't. React owns the component tree. It doesn't own the network. The moment you fire a request, you've created something that outlives React's opinion of it, and the only way to reel that back in is to have kept a handle on it.\n\nAbortController is that handle. It's fifteen lines of very unglamorous code, and I'd argue it's the difference between an app that *looks* responsive and one that's actually consistent with what the user is doing right now.\n\n
1If you've got a cancellation pattern that's held up better than the ref-and-cleanup version above â especially in bigger apps where requests fan out across several layers â I'd genuinely like to see it. Send it over.\n"},{id:"agent-playbook-evaluation-is-the-missing-loop",title:"Evaluation Is the Missing Loop in Agent Workflows",excerpt:"An agent that feels better after a prompt change may still be getting worse. Without a small set of realistic tasks, explicit expectations, and recorded failures, agent improvement is just a sequence of impressions.",tags:["AI Agents","Architecture","The Agent Playbook"],date:"Aug 26, 2026",readTime:"10 min read",coverImage:"/blog/agent-playbook-evaluation-is-the-missing-loop/cover.png",published:!0,content:"\nAgent workflows are unusually easy to improve by accident.\n\nYou change an instruction. The next run looks good. You add another rule. A different task breaks. You give the model a stronger prompt. The original failure disappears, but a new one appears somewhere else.\n\nAfter a few weeks, you have a growing pile of instructions and no reliable answer to the only question that matters: is the agent actually better?\n\n> If you do not replay the same work, you are measuring your mood about the last run, not the quality of the system.\n\n## The demo is not an evaluation\n\nA successful demo proves that one path worked once. Agent workflows need more than that because the system is probabilistic and stateful. Small changes in context, tool output, or task wording can produce a different path.\n\nAn evaluation starts with representative work. Real tickets. Real repository structure. Real conventions. Not a toy prompt that makes the workflow look clean.\n\nThe task should have a clear expected result, but not necessarily one exact sequence of actions. If the agent takes a different safe path and produces the right change, that should count as success. Evaluate the outcome and the important constraints, not theatrical obedience.\n\n## What to measure\n\nCorrectness comes first: did the implementation solve the task? Then check the engineering constraints: did it touch the right boundary, preserve existing behavior, run the required checks, and produce a reviewable change?\n\nThere are operational measures too. How many tool calls did the run take? How often did it ask for help? How much did it cost? Did it retry the same action? Did it stop at the right boundary?\n\nYou do not need a giant scorecard. Start with five or ten tasks that represent the work you actually care about and a short list of pass conditions for each.\n\nA task can pass while still teaching you something. The agent may have reached the result through a fragile route, used twice as many calls as necessary, or relied on a lucky document lookup. Record those details. Evaluation is not just a gate; it is a source of better design.\n\n## Keep failures concrete\n\n\"The agent was confused\" is not a useful failure record. Write what happened: it selected the wrong package because two tools had overlapping names; it guessed a story-point estimate when the ticket was ambiguous; it changed a shared component without checking its consumers.\n\nThen classify the failure. Was context missing? Was a tool too broad? Was the instruction ambiguous? Was the permission wrong? Did the task itself lack an expected outcome?\n\nThis classification keeps the fix proportional. Do not add a paragraph of prompt text to solve a permission problem. Do not add a new tool to solve a missing acceptance criterion.\n\n## Test the workflow, not only the final diff\n\nThe final code can look correct while the path to it is unsafe. An agent may edit production configuration, ignore a required check, or make a destructive action and then repair the result. The diff hides that story.\n\nFor important workflows, record the trace: the decisions, tool calls, approvals, retries, and stop conditions. You are looking for behavior that will fail on a slightly different task.\n\nThis is where the previous articles meet. The context should be sufficient. The tools should be narrow. The agent should ask at sharp edges. The guardrails should cap the run. Evaluation is the feedback loop that tells you which of those parts is weak.\n\n## Do not overfit the benchmark\n\nA benchmark can become another form of theater. If you keep rewriting the test until the agent passes, you are measuring how well the test has adapted to the agent.\n\nKeep some tasks private or rotate them. Add a fresh real task occasionally. Compare the agent's performance against a human review standard, not only against yesterday's score.\n\nAlso keep a manual path. An agent that passes a benchmark but makes work harder for the person reviewing it is not helping. The system should reduce cognitive load, not move the same uncertainty to the final reviewer.\n\n## The reframe\n\nPrompt editing feels like progress because the feedback is immediate. Evaluation feels slower because it asks you to repeat work you already did. That repetition is the point. Without it, every improvement is anecdotal and every regression can hide in a different task.\n\n
1I would rather have a small honest suite of real tasks than a dashboard full of synthetic numbers. The goal is not to prove that the agent is intelligent. It is to know whether it can perform the specific work I am asking it to perform, within the boundaries I am willing to give it.\n\nTake the last five agent tasks that required correction. Turn them into a tiny evaluation set. Write down what success means, replay them after the next change, and keep the failures visible. That is how an agent workflow becomes an engineered system instead of a collection of hopeful instructions.\n"},{id:"agent-playbook-memory-vs-context",title:"Memory Is Not a Bigger Context Window",excerpt:"A long transcript can remember everything and still help with almost nothing. Durable agent memory is the small set of current facts, decisions, and lessons that makes the next run start from a better baseline.",tags:["AI Agents","Architecture","The Agent Playbook"],date:"Aug 24, 2026",readTime:"9 min read",coverImage:"/blog/agent-playbook-memory-vs-context/cover.png",published:!0,content:'\nThe easiest way to misunderstand agent memory is to treat it as a transcript that never ends.\n\nKeep every message. Keep every tool call. Keep every correction. Eventually the agent will know everything that happened.\n\nIt will also have to search through everything that happened before making a simple decision, and it will not know which old decision is still valid. That is not memory. It is an attic.\n\n> Memory is not everything the agent has seen. It is what the next run needs to know without making the same mistake again.\n\n## A transcript records; memory changes the baseline\n\nA transcript is valuable for audit and debugging. It tells you what the agent did, what tools returned, and where the run went wrong. But a future session does not need the whole conversation. It needs the durable result.\n\nThe branch convention belongs in memory. The abandoned approach probably does not. The decision to ask before estimating an unclear ticket belongs in memory. The five speculative alternatives discussed before that decision probably do not.\n\nThis distinction matters because a transcript is chronological while a working memory should be organized around use. The agent needs current facts, active constraints, decisions, and lessons. It does not need to relive every path that led there.\n\n## Four useful kinds of memory\n\nI would keep project memory small and explicit.\n\n**Facts** describe stable reality: where the application lives, which command runs the checks, which service owns an endpoint, which branch is the production branch.\n\n**Decisions** record choices that future work must respect: why one workspace contains several repositories, why a boundary is intentionally strict, why a tool is read-only.\n\n**Lessons** capture failures and their prevention: update main before branching, do not use a broad mutation tool, stop when the same check fails repeatedly.\n\n**Tasks** describe what is active now. They should expire. A completed task is not a fact about the project and should not remain forever just because it once mattered.\n\nThe categories are useful because they give old information an expiration policy. Facts change slowly. Tasks change quickly. Lessons should be revisited when the workflow changes.\n\n## Current beats historical\n\nThe hardest memory bug is stale truth. An agent reads an old instruction that sounds authoritative and follows it after the codebase has moved on.\n\nEvery durable memory item needs an owner and a way to become obsolete. Keep dates where they help. Prefer one current entry over a pile of corrections. Remove a rule when the system no longer needs it. A memory store that only grows becomes another source of context pollution.\n\nThis is the same principle as code. Dead code is not harmless because it is still readable. Dead instructions are not harmless because an agent can still follow them.\n\n## Write memory at the correction point\n\nThe most useful time to update memory is immediately after a mistake has been understood. Not after a month of similar failures. Not as a quarterly cleanup exercise.\n\nThe entry should be short and operational: what happened, why it happened, and what rule prevents it. "The build failed" is not a lesson. "The sitemap imports every post, so an unescaped backtick in any content field breaks the build" is a lesson because it changes the next action.\n\nThis is how the agent improves without pretending the model itself has permanently changed. The next session inherits the correction because the environment carries it forward.\n\n## Memory needs boun
1daries too\n\nNot every project should share the same memory. A global preference can be useful. A repository-specific deployment rule can be dangerous when applied elsewhere. Scope matters: user memory, workspace memory, project memory, and task memory should not all be one undifferentiated folder.\n\nThe agent should know where a rule came from and how far it applies. A local convention is not a universal law. A temporary workaround is not architecture.\n\n## The reframe\n\nA bigger context window lets an agent carry more text. Good memory lets it carry the right lessons.\n\nThose are different capabilities. One delays forgetting. The other improves the starting point.\n\nWhen I add something to agent memory now, I ask whether it will change a future decision. If not, it probably belongs in a log, a document, or nowhere. Memory is a control surface for behavior, not a museum of conversations.\n\nOpen your current agent instructions and find one paragraph that no longer changes what the agent does. Delete it. Then find the last correction you repeated twice and turn it into one short rule. That is a better memory system than a thousand more lines of transcript.\n\nThe final question is how to know whether these rules actually improve the agent. Good intentions are not an evaluation strategy.\n'},{id:"agent-playbook-when-to-ask",title:"When an Agent Should Ask Instead of Acting",excerpt:"Autonomy is not a single setting to turn up. A useful agent knows which decisions it owns, which ones it can reverse, and which moments need a human before the next step.",tags:["AI Agents","Architecture","The Agent Playbook"],date:"Aug 22, 2026",readTime:"8 min read",coverImage:"/blog/agent-playbook-when-to-ask/cover.png",published:!0,content:'\nThe most frustrating agent is not the one that asks too many questions. It is the one that asks about harmless details and confidently guesses at the dangerous ones.\n\n"Which branch should I use?" after you have defined the convention is noise. "Should I push this change to production?" is exactly the question you want.\n\nThat difference is the whole design problem. An agent should not be maximally autonomous. It should be autonomous in the places where its decisions are clear, reversible, and within the rules you have agreed on.\n\n> Autonomy is not a volume knob. It is a map of decisions, boundaries, and consequences.\n\n## Not every uncertainty matters equally\n\nAn agent can be uncertain about a variable name and still make a safe choice. It should not be equally free to guess about an unknown business rule, a missing permission, or a destructive operation.\n\nI find it useful to separate decisions into three questions:\n\n- Is the intent clear?\n- Is the action reversible?\n- Is the consequence contained?\n\nWhen all three answers are comfortable, the agent can act. When one is uncertain, it may need a check. When several are uncertain, it should stop and ask before touching anything important.\n\nThis gives autonomy a shape. The agent is not trusted because it sounds confident. It is trusted because the decision sits inside a boundary we understand.\n\n## Make the safe path obvious\n\nAsking is not automatically good. An agent that responds to every small ambiguity with a question simply moves the work back to you. The goal is not to eliminate judgment. It is to reserve human judgment for the decisions where it has the highest value.\n\nThat means documenting defaults. If the branch name follows a convention, the agent should know it. If a missing estimate means "ask only when the ticket is genuinely unclear," write that down. If a draft pull request is safe but merging requires approval, make the two states distinct.\n\nThe agent should not ask because it lacks courage. It should ask because the workflow tells it this decision belongs to a person.\n\n## Use staged actions\n\nA dangerous action becomes easier to govern when it is split into stages. Instead of one tool that changes production, expose inspection, preview, and apply as separate steps.\n\nThe agent can read the current state. It can prepare the proposed change. It can show the effect. A person can approve the final operation.\n\nThis pattern is useful far beyond deployment. A pull request can be drafted before it is merged. A database migration can be generated and checked before it runs. A bulk update can produce a count and sample before it changes records.\n\nThe agent keeps momentum through the reversible work. The human appears at the sharp edge, where a mistake becomes expensive to undo.\n\n## Ask with a decision, not a shrug\n\nThere is a big difference between "I am not sure what to do" and a useful escalation. A good question contains the context, the options, the recommendation, and the consequence of each choice.\n\nFor example: "The ticket has no story points. I estimate it as five because it touches the policy form and validation flow, but the description does not say whether the API changes too. Should I use five, or wait for clarification?"\n\nThat is not the agent giving up. It has done the investigation and isolated the unresolved decision. The human only needs to resolve the part that genuinely depends on intent.\n\nPoor escalation pushes the whole problem back: "Please clarify the task." Good escalation makes the remaining uncertainty small.\n\n## Change the boundary when the workflow matures\n\nThe right boundary is not permanent. Early in a workflow, the agent may need a
1pproval to open a pull request. After enough successful runs, drafting the pull request can become automatic while merging remains protected.\n\nThis is how I would increase autonomy: one decision at a time, based on evidence. Do not grant a broad permission because the demo looked good. Move one reversible action across the boundary, observe the results, and keep the approval where failures would be costly.\n\nA rule that nobody revisits becomes a superstition. A rule that is changed after evidence becomes an operating model.\n\n## The reframe\n\nPeople often ask whether agents are ready to work autonomously. That question is too blunt to be useful. Ready to do what, with which permissions, under which stop conditions, and with what recovery path?\n\nA good agent can act independently for a long stretch and still be designed around human judgment. The human does not need to approve every keystroke. The human needs to own the decisions that carry intent, irreversible consequences, or unclear authority.\n\nLook at the last time your agent asked a question. Was it asking about the right thing? Now look at the last time it guessed. That comparison will show you where the boundary actually belongs.\n\nOnce the boundary is clear, the next challenge is continuity. An agent that learns a rule in one session but forgets it in the next is not autonomous. It is repeatedly starting over.\n'},{id:"agent-playbook-tools-are-intelligence",title:"Tools Are Part of the Agent's Intelligence",excerpt:"An agent cannot reason about information it cannot reach, and a vague tool description turns a precise capability into a guessing game. Tool design is not plumbing around the model. It is part of how the agent thinks.",tags:["AI Agents","Architecture","The Agent Playbook"],date:"Aug 20, 2026",readTime:"9 min read",coverImage:"/blog/agent-playbook-tools-are-intelligence/cover.png",published:!0,content:'\nPeople talk about giving an agent more tools as if they were adding buttons to a control panel. Add Jira. Add Confluence. Add GitHub. Add Postman. Now the agent is more capable.\n\nSometimes it is. Sometimes you have just built a larger menu of ambiguously named actions and made it harder for the agent to choose correctly.\n\nA tool is not an API wrapper from the agent\'s point of view. It is a piece of reasoning infrastructure. Its name, description, parameters, return value, permissions, and failure behavior all shape the decisions the agent can make.\n\n> A tool is part of an agent\'s intelligence because it determines what the agent can know and how safely it can act.\n\n## The hidden cost of a bad tool\n\nImagine a tool called update_issue. It accepts an issue ID and a free-form payload. Technically, it can do everything: change the status, assign an owner, move the issue to a sprint, add a comment, or alter an estimate.\n\nIt is also asking the agent to reconstruct your entire process from one dangerous verb.\n\nA better interface exposes the meaningful actions separately. Assign the issue. Move it to In Progress. Add it to the current sprint. Set story points. Each action has a narrower purpose and a smaller set of valid inputs. The agent has fewer ways to misunderstand the operation.\n\nThis is the same reason good application code uses domain functions instead of one universal mutate function. The boundary carries meaning.\n\n## Descriptions are executable design\n\nHumans can fill in missing details from experience. Agents cannot reliably do that, especially when several interpretations are plausible. A tool description should answer the questions an agent will have at the moment of choice:\n\n- What does this action change?\n- What does it leave untouched?\n- When should it be used?\n- What does success look like?\n- What happens when the requested state is already true?\n- Which actions are forbidden or require confirmation?\n\nA description that says "updates a ticket" is almost empty. A description that says "moves an assigned ticket into the current sprint without changing its estimate" gives the agent an actual contract.\n\nThe return value matters just as much. Returning "done" hides the new state. Returning the issue ID, status, assignee, sprint, and a clear error gives the next step something reliable to reason from.\n\n## Prefer tools that make wrong actions difficult\n\nThe best tools do not merely explain the right behavior. They make common wrong behavior hard to express. This is the poka-yoke idea applied to agent interfaces: shape the tool so a mistake becomes impossible or obvious.\n\nA tool that opens a pull request should not silently choose the base branch. A deployment tool should expose the target environment explicitly. A production mutation should not look identical to a staging mutation with one invisible str
1ing changed.\n\nNarrow parameters help. Typed enums help. Read-only previews help. Separate approval actions help even more. The agent should be able to inspect what it is about to do before it commits to the irreversible part.\n\nThat is not excessive ceremony. It is how you move risk out of the model\'s free-form language and into a boundary you can test.\n\n## One excellent tool beats five overlapping ones\n\nTool count is another place where agent design starts to resemble multi-agent design. More looks more capable. In practice, overlapping tools create routing uncertainty. The agent spends tokens deciding which nearly identical action to call, then has to recover when the chosen tool has subtly different semantics.\n\nI would rather expose one clear tool for a meaningful operation than five endpoints that reflect how the backend happens to be organized. The agent does not need to see your internal service decomposition. It needs a stable action model.\n\nThis is also where MCP servers can either help or hurt. Connecting Jira, Confluence, docs, and Postman gives an agent access to the world. But access alone is not context. Each server still needs coherent names, useful descriptions, predictable errors, and permissions that match the workflow.\n\n## Design tools from the failure backwards\n\nStart with a real mistake. The agent branched from stale main. It guessed a story-point estimate. It used the wrong API endpoint. It opened a pull request without the required context.\n\nNow ask which tool boundary allowed the mistake. Was the information missing? Was the action too broad? Could the tool have returned the state the agent needed? Did the agent have a safe way to ask for confirmation?\n\nFix the boundary, not just the prompt. A prompt correction helps one context. A better tool helps every future context that reaches the same decision.\n\n## The reframe\n\nWe usually describe tools as the agent\'s hands. I think they are closer to its senses and its joints. They determine what it can perceive, which movements are available, and where the force of a bad decision lands.\n\nA strong model with weak tools is a talented engineer working through a broken dashboard. A modest model with clear, narrow, truthful tools can often do much better because the environment removes whole categories of error.\n\nLook at the most important tool your agent uses today. Can it explain its contract? Can it show the state it changed? Can it make a dangerous action explicit? If not, that tool is not finished just because the API call works.\n\nThe next boundary is not a tool at all. It is the moment where the agent should stop, surface uncertainty, and let a person decide.\n'},{id:"agent-playbook-context-is-the-product",title:"The Context Is the Product",excerpt:"A capable model with the wrong context is still a bad agent. The useful part of an agent is not the model in isolation, but the project knowledge, rules, tools, and decisions that shape what it can see before it acts.",tags:["AI Agents","Architecture","The Agent Playbook"],date:"Aug 18, 2026",readTime:"9 min read",coverImage:"/blog/agent-playbook-context-is-the-product/cover.png",published:!0,content:'\nA model can be brilliant and still be useless in your project.\n\nGive it a ticket with no surrounding context, a repository with no map, and a handful of tools with vague descriptions. It will produce something plausible. That is exactly the problem. Plausible is not the same as correct.\n\nI used to think of context as the thing I supplied to an agent before the real work began. The prompt, the files, the issue. Now I think that is backwards. Context is not the preamble to the work. Context is the product I am building around the model.\n\n> An agent is only as good as the world it can see before it makes a decision.\n\n## The model is not the whole system\n\nThe model gets most of the attention because it is the visible intelligence. But an agent run is closer to a small operating system than a chat completion. It has a goal, a working memory, tools, permissions, project rules, and a history of decisions. Change any of those and the same model behaves differently.\n\nThat is why comparing agents by model name alone is incomplete. Two teams can use the same model and get completely different results. One gives it the repository map, the conventions, the API documentation, and a clear stop condition. The other gives it a ticket and hopes the model guesses the rest.\n\nThe second team is not using a weaker model. It is giving the model a weaker world.\n\n## Context has layers\n\nThe first layer is the task: what needs to change and why. A ticket that says "fix the payment flow" is not a task. It is a label. A useful task includes the observed behavior, the expected behavior, the boundary of the change, and the check that proves it is done.\n\nThe second layer is the project: where code lives, which package owns the behavior, how data moves, and which commands validate a change. This is the map that stops an agent from editing the first file whose name looks relevant.\n\nThe third layer is convention. Naming, branching, testing, commit messages, API boundaries, and the moments where the agent must ask instead of guessing. Conventions turn a technically possible action into an acceptable one.\n\nThe fourth layer is history. Why is this abstraction here? Which migration is still in progress? Which shortcut is deliberate? Without that history, an agent sees an odd line and helpfully removes the reason it exists.\n\nThe last layer is access. A tool is part of context because it changes what the agent can know and do. Read-only access to Jira creates one kind of workflow. The ability to update a ticket and open a pull request creates another.\n\n## More context is not always better\n\nThere is a trap here. If missing context c
1auses mistakes, the natural reaction is to attach everything. Every document, every repository, every old discussion. That creates a larger transcript, not necessarily a clearer world.\n\nGood context is selected context. It answers the next decision the agent has to make. A small project map is better than a directory dump. A short rule about API ownership is better than a hundred pages of loosely related documentation. A current decision record is better than three contradictory conversations.\n\nThe test is simple: can the agent tell which information is authoritative? If it cannot, more context increases the number of ways it can be confidently wrong.\n\n## Build the context deliberately\n\nI would start with four durable pieces: a project map, a set of working rules, a decision log, and a list of available tools. Keep each one short enough to remain current. A stale instruction is worse than no instruction because it makes the wrong action look official.\n\nThen connect the context to the workflow. When a task arrives, the agent should be able to discover the relevant feature, read the owning documentation, inspect recent decisions, make the smallest change, and run the expected check. That sequence is more valuable than a clever initial prompt.\n\nThis is also why the investment compounds. Every correction that becomes a durable rule improves the next run. Every tool that removes a guess improves the next decision. Every obsolete instruction you delete removes one false path.\n\nThe agent is not becoming magically smarter. The environment around it is becoming more legible.\n\n## The reframe\n\nThe exciting part of agents is usually the model. The durable advantage is the context architecture around it. Models will change. A clear project map, coherent rules, useful tools, and visible decisions will keep paying rent.\n\nSo when an agent keeps making the same kind of mistake, I do not begin by asking whether I need a stronger model. I ask what part of the world was invisible, ambiguous, or contradictory at the moment it decided.\n\nThat question moves the work upstream. You stop polishing prompts and start designing the environment in which decisions happen. The next question is obvious: if tools define what the agent can see and change, tool design is part of the agent\'s intelligence too.\n\nTake one recurring correction from your last agent session and ask what context would have prevented it. Write that context where the next session can actually find it. That is the beginning of the product.\n'},{id:"agent-playbook-guardrails",title:"Guardrails for a Runaway Agent",excerpt:"An agent is stateful, and its errors compound: one wrong step sends it down an entirely different path, and it can't tell that it's lost. Autonomy without guardrails isn't trust â it's hoping. The job isn't to make the agent never fail. It's to make sure that when it does, the blast radius is small.",tags:["AI Agents","Architecture","The Agent Playbook"],date:"Aug 14, 2026",readTime:"10 min read",coverImage:"/blog/agent-playbook-guardrails/cover.png",published:!0,content:"\nEverything in this cluster has circled the same failure: an agent that runs longer, spends more, and drifts further than you intended, with nothing to stop it. The [multi-agent orchestra](/blog/agent-playbook-you-dont-need-multi-agents) fractures context, the [token bill](/blog/agent-playbook-token-bill) balloons, the [scheduled loop](/blog/agent-playbook-test-on-change) burns all night. Underneath all of them is one uncomfortable property, and Anthropic name it directly: agents are stateful, and their errors *compound*. One bad step doesn't just produce one bad result â it changes the state the next step reasons from, so the agent quietly walks off in a completely different direction and has no idea it's lost.\n\nThat's the thing traditional software doesn't really do. A bug usually breaks a feature and stops. An agent's bug can send it on a confident, expensive journey to nowhere. So the design question isn't \"how do I make the agent never fail?\" â you can't. It's \"when it fails, how do I make sure it fails *small*?\"\n\n> You don't grant an agent autonomy by trusting it. You grant it autonomy by bounding what it can do when you're wrong to trust it.\n\n## Cap the loop\n\nThe first guardrail is the dumbest one and the most important: a hard ceiling on how many steps or iterations a run can take. Anthropic build exactly this into their agents â a maximum number of iterations as a stopping condition, so a task that can't converge *terminates* instead of spinning. This is the single line that turns the overnight-burn story into a non-event: the agent tries twenty times, hits the cap, stops, and tells you it's stuck. \"Burned all night\" becomes \"gave up before breakfast.\"\n\n## Budget the spend\n\nThe step cap bounds *count*; a spend cap bounds *cost* directly. Give a run a token or dollar budget and a circuit breaker that trips when it's exceeded. Even if you got the iteration ceiling wrong, the budget is a second, independent wall between a stuck agent and a catastrophic invoice. Two different limits guarding the same failure is not redundancy â it's the point. The whole philosophy here is that any single guardrail might be the one you misconfigured.\n\n## Notice when it stops making progress\n\nA cap stops an agent eventually; progress-detection stops it *sooner*. If the agent takes the same action, gets the same result, and tries again â that's not work, that's a loop, and you can detect it. Repeated identical tool calls, the same error over and over, no change in state across steps: these are all signals that the run has stopped advancing and should be cut short. It's the difference between letting it exhaust twenty iterations and killing it at the third pointless repeat.\n\n## Put a human at the sharp edges\n\nNot every action deserves the same freedom. Reading a ticket, running a test, opening a draft PR â low consequence, let it run. Deleting data, pushing to a shared branch, spending real money, touching production â these are where autonomy should pause and check with a person. This isn't timidity; it's the [same negotiated boundary](/blog/agent-playbook-do-it-as-usual) that makes \"do it as usual\" safe to run unattended. The agent owns the reversible actions and stops at the irreversible ones. You decide where that line sits per action, deliberately.\n\n## Shrink the blast radius\n\nThe guardrails above assume the agent *tries* something bad; sandboxing assumes it *succeeds* and makes that survivable. Least privilege is the whole idea: an agent that physically cannot reach production can't take it down, no matter how confused it gets. Ru
1n in an environment where the worst thing it can do is still recoverable â a scratch branch, a staging database, scoped credentials. Then a runaway is an annoyance, not an incident.\n\n## See why it ran away\n\nYou can't tighten a guardrail you can't see through. Anthropic lean hard on tracing agent decisions for exactly this reason â when an agent \"can't find obvious information\" or spirals, the only way to fix it systematically is to look at what it actually did, step by step. Observability isn't a nice-to-have bolted on at the end; it's how you learn which guardrail was missing. Every runaway you can inspect becomes a rule you can add.\n\n## The reframe\n\nThere's a temptation to read all of this as distrust â as clipping the agent's wings. I see it the opposite way. Guardrails are what let me hand an agent *more* autonomy, not less, because I know the failure modes are bounded. The reason I can say \"do it as usual\" and walk away is precisely that a stuck run caps out, a runaway can't reach anything that matters, and I can see afterward what went wrong. The bounds are what make the trust affordable.\n\nAn agent without guardrails isn't a bold, high-trust setup. It's an unbounded process with a credit card and access to your systems, and the only thing standing between it and a bad night is luck. Design the blast radius first, and autonomy stops being a gamble. That's the through-line of this whole cluster: the goal was never a more impressive agent. It was a *sane* one â one you can actually let run, because you decided in advance how it's allowed to fail.\n\nTake one agent you run unattended and write down what happens if it gets stuck right now: does it stop on its own, and what can it reach while it's confused? If either answer is uncomfortable, you've found your next guardrail. Tell me which one â I'd bet it's the iteration cap, because it almost always is.\n"},{id:"agent-playbook-test-on-change",title:"Test on Change, Not on a Timer",excerpt:"The instinct is to put the agent on a schedule: every few minutes, wake up and check the environment. That instinct is where a lot of runaway costs and pointless runs come from. An agent should be woken by a reason â a commit, a deploy, a failing check â not by a clock ticking over whether or not anything happened.",tags:["AI Agents","Architecture","The Agent Playbook"],date:"Aug 12, 2026",readTime:"9 min read",coverImage:"/blog/agent-playbook-test-on-change/cover.png",published:!0,content:'\nGo back to the [overnight-burn story](/blog/agent-playbook-token-bill) for a second, because the detail that matters most is the one everyone skips past. The agent was on a *schedule*. It woke up on a timer to check the environment, over and over, whether or not anything had actually changed. The loop was the accident. The schedule was the design flaw that made the accident possible.\n\nAnd it\'s such a natural design flaw. When you want an agent to keep something healthy, the first thing your brain reaches for is a cron job: "every N minutes, go check." It feels responsible â always watching, always on. But a timer is a fundamentally lazy trigger. It fires on the passage of time, which is almost never the thing you actually care about.\n\n## A timer runs when nothing happened\n\nThink about what a scheduled agent actually does most of the time: it wakes up, looks at an environment that is *exactly* as it left it, does a bunch of work to confirm nothing changed, and goes back to sleep. You\'re paying full agent price â remember, [4\xd7 a chat and up](/blog/agent-playbook-token-bill) â to repeatedly discover that nothing is different.\n\nAnd on the rare occasion something *is* wrong, the timer is no help there either. If the environment breaks thirty seconds after a run, your schedule sits on that failure until the next tick. So you tighten the interval to catch problems faster, which means even more empty runs, which means more cost and more surface area for exactly the kind of loop that burned all night. The timer is bad at both jobs: it\'s wasteful when things are fine and slow when they aren\'t.\n\n> A clock is not an event. Waking an agent on a schedule means waking it for no reason, most of the time â and paying for the privilege.\n\n## Wake it on a reason\n\nThe alternative is almost obvious once you name it: run the agent when something *changes*. Not "every five minutes," but "when a commit lands," "when a deploy finishes," "when a check goes red," "when this file is updated." The trigger is an event that carries actual meaning, and the agent\'s run is a response to that meaning.\n\nThis flips every problem the timer had:\n\n- **No empty runs.** No change, no event, no run. You stop paying to confirm the status quo.\n- **Immediate response.** The agent reacts the moment the thing it cares about happens, not on the next tick.\n- **Naturally bounded work.** One event produces one run with a clear scope: "here\'s what changed, deal with it." That\'s a far easier task to reason about â and to cap â than an open-ended "go check everything."\n- **Debuggable history.** Every run traces back to a specific event. When something misbehaves, you know exactly what set it off, instead of squinting at a timeline of identical scheduled wake-ups.\n\nTesting is the cleanest example. Running your whole suite on a timer is nonsense â nothing changed, so nothing new can break. Running it when code changes is the entire point. The event *is* the reason the test is worth running. That principle generalizes: the right moment to act is the moment the world became different, because that\'s the only moment new information exists.\n\n## The honest exceptions\n\nI don\'t want to pretend timers never have a place. Some work genuinely is periodic: a nightly summary, a daily digest, a report that\'s tied to the calendar rather than to a change. If the *business meaning* is "once a day," then a schedule is correct â the time itself is the event.\n\nBut even then, two rules hold. First, it should still be bounded hard, because "runs on a schedule" and "can run forever once started" are independent properties, and the second one is what bites you. Second, be honest about whether the task is *actually* periodic or whether you reached for a timer because it was the easy primitive. Most "check the environment every N minutes" jobs are change-driven work wearing a schedule\'s clothing.\n\n## The reframe\n\nThe shift from timer to event is small in code â a webhook instead of a cron entry â and large in consequences. It\'s the difference between an agent that runs because a clock ticked and an agent that runs because something happened that it should respond to. The first is a machine idling in y
1our driveway, burning fuel to go nowhere. The second only starts when there\'s somewhere to go.\n\nThat overnight bill wasn\'t really caused by a loop. The loop was the spark. The schedule was the pile of dry wood that let a single spark burn until morning. Trigger the agent on change instead, and most of that wood is never there to catch â the agent simply isn\'t running when there\'s nothing to do. What you still need is a firebreak for the runs that *do* go wrong, and that\'s the last piece: [the guardrails that cap the damage](/blog/agent-playbook-guardrails).\n\nFind one scheduled agent job you\'re running and ask what event it\'s really waiting for. If you can name the change it\'s checking for, you can trigger on that change directly â and delete the clock. Tell me what the event turned out to be; naming it is usually the moment the cron job stops making sense.\n'},{id:"the-outside-view",title:"You Only Get to See It From the Outside Once",excerpt:"The most valuable thing an experienced engineer brings to a new project isn't a skill â it's a vantage point. For a short window, you can still see the thing from the outside, the way a user or a newcomer does. Six months in, that view is gone. And most teams burn it in the first week without noticing.",tags:["Engineering Culture","Leadership","Career"],date:"Aug 11, 2026",readTime:"10 min read",coverImage:"/blog/the-outside-view/cover.png",published:!0,content:"\nI was in the middle of writing this article when my wife started giving me notes on it. And I did the thing I apparently do â I pushed back. I explained that engineering is different on the inside, that the processes don't work the way she was picturing, that she was missing context. I had reasons. I always have reasons.\n\nShe let me finish. Then she pointed out, pretty gently, that I had just acted out the entire premise of the article I was writing. She was the outsider looking at my argument with fresh eyes. I was the one who had already climbed inside it and could no longer see it straight. On an article that is *specifically about that*. She was out of the box. I was in it. And I couldn't tell until she said so.\n\nThat small, slightly embarrassing moment is the whole thing I want to talk about.\n\n## The asset nobody writes on the offer letter\n\nWhen an experienced person â an architect, a staff engineer, a senior hire â joins a project, they show up with something that has nothing to do with their r\xe9sum\xe9. For a short window, they can see the project *from the outside*. They notice the thing that makes no sense. They ask \"why is it like this?\" about the part everyone else stopped seeing a year ago. They feel the friction a new user feels, because right now they basically *are* a new user.\n\nThat vantage point is not a bonus on top of the \"real\" work. For a lot of senior hires, it *is* the work â or it should be. You're not paying a premium for another pair of hands to type features. You're paying for someone who can still look at the whole thing and tell you, honestly, what's wrong with it before they've been anesthetized into thinking it's normal.\n\n> The outside view is the most valuable thing an experienced engineer brings â and it's the first thing a team destroys without meaning to.\n\n## It has a shelf life\n\nHere's the cruel part. That view is perishable. It doesn't last. Six months in, you've internalized the map. You know where everything lives, which means you've stopped noticing that nothing *should* live there. You know why the weird thing is weird â there's a Slack thread, a migration, a reason â and knowing the reason is exactly what makes you stop seeing that it's a problem. You've learned where the bodies are buried, so you no longer notice you're standing in a graveyard.\n\nKeeping the outside view alive past that honeymoon is genuinely hard. It takes deliberate effort from the person, and â much more â a culture that protects it. Most places have neither. So the clarity that walked in the door on day one quietly evaporates, and in a year that same brilliant hire is defending the exact weirdness they were hired to question.\n\n## How teams burn it on day one\n\nThe way companies destroy this asset is almost always the same, and it's well-intentioned. They hire a senior person because there's a hot new feature, or a new branch of the product, or a fire. And then, on day one, they submerge them: *\"Great, let us walk you through how everything works here.\"* Onboarding as immersion. The clock starts, the deadlines are already there, and the expectation is feature output by roughly week two.\n\nThink about what that does. You took the one person who could still see the project from the outside, and you rushed them inside as fast as possible. You spent their most valuable, most perish
1able asset on getting them productive on tickets â and you never once asked them the question only they could answer for a few short weeks: *what looks wrong to you right now, before you get used to it?*\n\nWhat they actually needed was a little room. Time to pick the thing up, turn it over, feel how it behaves, and notice. Time to reflect. That's not slacking; that's the senior work. But reflection has no line in the sprint, so it's the first thing the deadline eats.\n\n## I hit this wall myself\n\nLet me be specific, because I lived the other side of this. A few years back I joined a Canadian health-tech company in a good, senior role. I was there about six months, and then we parted ways â not dramatically, but because what they wanted and what I was doing had quietly stopped being the same thing.\n\nI did a lot in those months. I set up practices, fixed how a bunch of things were used. Their setup was wild in places â a designer could change a button through an AI tool more or less directly. There was review, technically. But there was essentially *zero* consistency in the code. And every time I actually picked the project up to work in it, it came apart in my hands. Long legacy, dressed in modern libraries, with no architecture underneath â you genuinely could not tell where anything lived or why.\n\nHere's the mismatch. What I was offering them was the outside view: I'm looking at this thing fresh, and I'm telling you it doesn't hold together. That was the most useful thing I had. But what they wanted, increasingly, was for me to stop looking and start shipping â hands inside the box, features out the door. They'd hired a senior and then asked him to stop doing the senior part. I couldn't unsee what I was seeing, and they didn't want to act on it. So it ended.\n\nI'm not telling that story to settle a score. Honestly, I get it â the pressure to ship is real and it's not villainy. I'm telling it because it's the clearest example I have of a company holding a valuable asset and spending it on the wrong thing. They paid for an outside view and then treated it as an obstacle to velocity.\n\n## For the manager, and for the senior\n\nSo this cuts two ways, and I want to be clear about both, because the two audiences own different halves of the fix.\n\nIf you *manage* senior hires: the outside view is an asset with an expiry date, and you are almost certainly burning it. The window where a new architect or staff engineer can tell you what's actually wrong is measured in weeks, not quarters. Protect it. Before you submerge them in \"how we do things here,\" ask them what looks broken while they can still see it â and write it down, because in three months they won't be able to tell you anymore. And then think about the harder, cultural problem: how do you keep *anyone* on the team seeing the product from the outside once the honeymoon ends? That's the real work of engineering culture, and it doesn't happen by accident.\n\nIf you *are* the senior: your value was never the volume of code you produce. Plenty of people can produce code. Your rare, decaying advantage is that right now you can still see the box from the outside. Guard it. In your first weeks somewhere new, write down everything that looks wrong, everything that makes you go \"wait, why?\" â because that document is worth more than the features you'll ship in that same period, and you will not be able to write it later. You'll have gone native.\n\n## The reframe\n\nThe thing that still gets me about the moment with my wife is how fast it happened, and how invisible it was to me. I wasn't being stubborn on some project I'd spent years on. I'd been inside *my own article* for a couple of days, and that was already enough to make me defend it against exactly the kind of outside perspective the article is arguing you should protect. If it happens that quickly to me, on a piece of writing, imagine what six months of deadlines does to a person's ability to see the system they work in.\n\nThe outside view isn't a personality trait. It's a position â you're either standing outside the thing or you're inside it â and you lose it the moment you step in and get comfortable. Which means the whole game, for teams and for individuals, is noticing that the window is open right now, and using it before it quietly closes.\n\nThink about the last time someone from outside your project â a new hire, a designer, someone who doesn't live in the code â told you something was wrong, and your first instinct was to explain why they didn't understand. That instinct is the tell. I'd genuinely like to know what they were seeing that you couldn't â reach out and tell me, because odds are they were standing exactly where you used to.\n"},{id:"agent-playbook-token-bill",title:"The Token Bill Is Part of the Architecture",excerpt:"A story made the rounds: someone put an agent on a schedule to keep checking their environment, it got stuck in a loop one night, and instead of a couple of hours it burned through a fortune in tokens by morning. The lesson isn't \"watch your usage.\" It's that cost is a design constraint, and most agent setups treat it as a footnote.",tags:["AI Agents","Architecture","The Agent Playbook"],date:"Aug 10, 2026",readTime:"10 min read",coverImage:"/blog/agent-playbook-token-bill/cover.png",published:!0,content:"\nThere's a story that made the rounds a while back, and I keep thinking about it. Someone set up an agent to run on a schedule â every so often it would wake up and check their environment, run some tests, make sure things were healthy. Reasonable idea. The
1n one night something went wrong, the agent got stuck in a loop, and instead of running for the couple of hours it was supposed to, it churned all night. By morning it had burned through an eye-watering pile of tokens doing nothing useful.\n\nIt's easy to read that as \"lol, watch your usage.\" I read it differently. That bill wasn't an accident of carelessness â it was a design that never treated cost as real. The agent had no sense that its own runtime cost money, and nothing in the system did either. And that's the norm, not the exception. We design agent workflows around capability and correctness, and we bolt cost on afterward as a thing to monitor. I think that's backwards.\n\n> Cost isn't something you check after the fact. With agents, it's a first-class architectural constraint â as much a part of the design as latency or correctness.\n\n## Agents are expensive in a way chat isn't\n\nLet me put real numbers on it, because the gap is bigger than people expect. Anthropic published figures from their own systems: an agent uses roughly **4\xd7 the tokens** of a normal chat interaction, and a multi-agent system about **15\xd7**. Every autonomous loop, every \"let me check that again,\" every re-read of a big context window â it all adds up per step, and agents take a *lot* of steps.\n\nThis is why Anthropic's own top-line advice for building with LLMs is to find the simplest thing that works and only add complexity when it demonstrably pays. That's not aesthetic minimalism. It's economics. Agentic autonomy trades latency and money for flexibility, and if the task didn't need the flexibility, you just paid a premium for nothing.\n\n## Where the money actually leaks\n\nOnce you start looking at cost as architecture, you start seeing where it drains:\n\n**Unbounded loops.** The overnight-burn story is the pure form. An agent that can retry forever will, eventually, retry forever. Without a hard ceiling, one stuck state is an open tap.\n\n**Agents pinging agents.** The multi-agent orchestras I complained about in [the first article](/blog/agent-playbook-you-dont-need-multi-agents) don't just fracture context â they multiply token spend. One agent asking another \"are you done yet?\" is a paid API call every time it asks.\n\n**Re-sending the world every turn.** If every step drags the entire history back through the model, your cost grows with the square of the conversation. Long-running agents that don't compress or summarize their context pay for their whole past on every single step.\n\n**Reaching for a big model on easy work.** Routing a trivial classification through your most capable, most expensive model is money set on fire. Anthropic explicitly route easy, common cases to a small cheap model and reserve the expensive one for the hard cases.\n\n## Designing like the meter is running\n\nNone of these are exotic to fix. They just require treating the token meter as a signal you design against, the same way you'd design against a latency budget:\n\n- **Put a ceiling on every loop.** A hard cap on iterations or steps turns \"burned all night\" into \"gave up after twenty tries and told me.\" This is the first guardrail, and it's the subject of [its own article](/blog/agent-playbook-guardrails).\n- **Give long tasks a memory, not a longer transcript.** Summarize completed phases and store the essentials, so each step reasons over a compact context instead of re-paying for the entire history.\n- **Match the model to the job.** Cheap model for routing and simple calls, expensive model where the reasoning actually earns it.\n- **Prefer a workflow to an autonomous loop when the task is well-defined.** A fixed pipeline of LLM calls is predictable in both behavior *and* cost. You reach for open-ended autonomy when you truly can't predict the steps â not by default.\n\n## The reframe\n\nThe overnight-burn story gets told as a cautionary tale about monitoring, but monitoring is the weakest possible response â it tells you about the fire after it's out. The real fix is upstream, in the design: an agent whose cost is bounded by construction can't run up that bill in the first place, because the ceiling, the compaction, and the stop conditions were part of the architecture, not a dashboard y
1ou forgot to check.\n\nSo I've started treating the token bill the way I treat a slow render or a memory leak: as a property of the system I'm responsible for shaping, not an invoice that shows up later. When I sketch an agent workflow now, \"what does one run cost, and what stops it from costing a hundred times that?\" is one of the first questions, not the last. The story that made the rounds is only a horror story if cost was never in the design. Put it in, and the worst case is a bounded disappointment instead of a night's worth of tokens gone.\n\nThe next two articles follow this thread straight down: [when to actually run the agent](/blog/agent-playbook-test-on-change) â because that scheduled loop was the root of the burn â and [the guardrails](/blog/agent-playbook-guardrails) that cap the damage when a run goes wrong anyway.\n\nGo look at one agent workflow you run and answer a single question: what's the most it could spend if it got stuck right now? If you don't know, that's the number an attacker on your patience â a loop â already knows. Tell me what you find; the not-knowing is the whole problem.\n"},{id:"agent-playbook-do-it-as-usual",title:"Do It As Usual: Teaching One Agent Your Whole Workflow",excerpt:"I can open a fresh session, type \"take ticket 123 and do it as usual,\" and the agent runs the entire routine â assign, sprint, branch, commit, PR, pipeline. People assume that's a clever prompt. It isn't. It's months of small corrections turned into rules the agent never forgets.",tags:["AI Agents","The Agent Playbook"],date:"Aug 08, 2026",readTime:"12 min read",coverImage:"/blog/agent-playbook-do-it-as-usual/cover.png",published:!0,content:"\nHere's a thing I can do that sounds like a party trick. I open a brand-new session with my agent and type, almost word for word: \"take ticket 123 and do it.\" And it does â the whole thing, end to end, without me hovering. Not because I found a magic prompt. Because I spent months teaching it what \"as usual\" means.\n\nI bring this up because of a complaint I keep hearing, some version of: *\"I can't get my agent to just do a task. I set it loose and then I have to walk behind it fixing everything.\"* And look â if you started last week, that's completely fair. I couldn't either at first. But when someone's been at it for months and still can't get the agent to carry a task cleanly, I get suspicious. Not of the agent. Of how they're working with it.\n\n## \"As usual\" is a lot of things\n\nLet me show you what actually happens when I say \"do it as usual,\" because the phrase is hiding an entire workflow the agent has internalized:\n\n- It takes the ticket and assigns it to me.\n- It moves it to In Progress.\n- If the ticket was sitting in the backlog, it pulls it into the current sprint.\n- If it has no story points, it estimates it â and if it genuinely can't tell, it asks me, the way we agreed it would.\n- It creates a branch by our naming convention, after first updating main so it's not branching off something stale.\n- It makes the changes, committing as it goes with human-readable messages that say what was actually done.\n- It opens the pull request, writes a real description, adds the necessary context and links, mentions the people who need to see it, and links the PR back to the ticket.\n- Then it waits for the pipelines, checks whether anything went red, and fixes it if it did.\n\nAll of that from four words. And none of it was true on day one.\n\n## The part nobody wants to hear\n\nThat list didn't arrive as a single clever configuration. It arrived one failure at a time. The agent would branch off stale main â so I taught it to update first. It would write commit messages like \"fix stuff\" â so we worked out what a human-readable message looks like. It would guess a story-point estimate confidently when it had no business guessing â so we agreed that when it's unsure, it stops and asks me instead of inventing a number.\n\n> Every step in \"as usual\" is a scar. It's there because something once went wrong, and I turned the fix into a rule instead of re-explaining it every session.\n\nThat's the whole method, honestly. It's not prompt cleverness. It's accumulation. Each time the agent got something wrong, I didn't just correct it for that one run â I wrote the correction down somewhere durable, so the next session started from the corrected baseline. Do that for a few months across a dozen steps, and \"as usual\" stops being a phrase and becomes a reflex the agent actually has.\n\n## Give it real context, not guesses\n\nThe second half of this is tooling. An agent working from guesses is an agent you'll always be correcting. So I connected the things it needs to *know* the state of the world instead of hallucinating it: an MCP server to read Jira, another for Confluence, one for our docs, one for Postman â the tools the work actually runs on.\n\nThe difference is night and day. When the agent can read the ticket, it doesn't misunderstand the task. When it can see the docs and the API collection, it doesn't invent an endpoint. The tools aren't there to make the agent look impressive â they're there so its decisions are grounded in what's real. This is the [augmented-LLM idea](/blog/agent-playbook-you-dont-need-multi-agents) done properly: one agent, made capable by good tools, is worth more than a crowd of agents guessing at each other's intent.\n\n## One agent, not one per repo\n\nI told this story in [the multi-agents piece](/blog/agent-playbook-you-dont-need-multi-agents), but it belongs here too, because it's the same instinct. My project spans six or seven repositories. My first move was to set up an agent in each one. Then I noticed my tasks almost never span repos â they live in one, and multi-repo changes have come up maybe twice. So instead of maintaining seven configurations, I put every repo in one workspace and taught one agent. Every hour I *didn't* spend duplicating setup across seven repos, I spent making the single one better. That's why it can do \"as usual\" at all â the investment was concentrated, not scattered.\n\n## The decision points matter most\n\nThe subtle part â the thing that separates an agent that helps from one you babysit â is deciding where it should act and where it should stop and ask. I didn't tell it to just do everything autonomously. We agreed on the seams. Estimation it can't confidently do? Ask. Convention it knows cold? Just do it. That negotiated boundary is what makes \"as usual\" safe to run unattended. The agent isn't guessing about my intent at the risky moments, because we already talked through those moments and it knows the rule.\n\nThis is why \"just be more autonomous\" is bad advice. Autonomy isn't a slider you crank to the top. It's a map of which decisions the agent owns and which ones come back to you â and you build that map the same way you build everything else here, one correction at a time.\n\n## The reframe\n\nSo when I hear \"I can't get my agent to do a task in one shot,\" what I mostly hear is a missing investment. People want the \"do it as usual\" outcome without the months of turning mistakes into durable rules and wiring in the tools that ground the agent in reality. The agent that runs my whole ticket workflow from four words isn't smarter than yours. It's just been *taught* more, and the teaching stuck because I wrote it down instead of repeating it.\n\nThe uncomfortable truth is that this looks a lot less like prompting and a lot more like onboarding a junior engineer who happens to have perfect recall. You explain the convention once, correctly, and it never forgets it â but only if you actually captured it instead of hand-waving it in a single session that evaporates when the context does.\n\nPick the one task you do most often with your agent and write down every correction you find yourself repeating this week. That list is the start of your own \"as usual.\" Send it to me if you want a second pair of eyes â I'll tell you which corrections are rules and which are you doing the agent's thinking for it.\n"},{id:"agent-playbook-you-dont-need-multi-agents",title:"You Probably Don't Need Multi-Agents",excerpt:"Open almost any \"how I use AI\" post and you'll find an orchestra: a planner agent, a coder agent, a reviewer agent, a tester agent, all pinging each other. It looks impressive. It's also the most expensive, most fragile way to get worse results than one agent you actually taught to do the job.",tags:["AI Agents","Architecture","The Agent Playbook"],date:"Aug 06, 2026",readTime:"11 min read",coverImage:"/blog/agent-playbook-you-dont-need-multi-agents/cover.png",published:!0,content:"\nI work across six or seven repositories on one project â a big hybrid thing, part microfrontend, part backend, several apps that all talk to each other. When I started bringing an AI agent into that work, my first instinct was the obvious one: set up an agent in each repo. Seven repos, seven configured agents. It felt right. Each repo is its own world, so each gets its own helper.\n\nI got a couple of repos in before I stopped and asked myself what I was actually doing. My tasks almost never live in seven repos at once. They live in *one*. In months of work I've had maybe two tasks that touched multiple repos. So I was about to build and maintain seven separate agent setups to serve a reality where the work is overwhelmingly single-repo. I threw it out, put every repo into one workspace, and configured one agent, once. That agent has been better than any swarm I could have wired together â and it cost me a fraction of the effort.\n\nThat small decision is the whole argument of this article, scaled up. The industry's default reflex right now is *more agents*. I think the default should be *one agent, taught well* â and you should have to justify every agent you add past the first.\n\n## The orchestra is a status symbol\n\nHere's the thing that bugs me about most \"how I use AI\" posts. They read like a flex. \"I built an orchestrator that spawns a planner, which hands off to a coder, which pings a reviewer, which triggers a tester.\" Eight agents, a diagram with lots of arrows, and the unspoken message: look how sophisticated my setup is.\n\nAnd honestly, some of it *would* work fine â if the coordination between the pieces were as good as the diagram implies. But that's exactly the part nobody shows you. The arrows in the diagram are doing an enormous amount of invisible work. In practice, an agent that pings another agent â \"are the tasks ready yet?\", \"go run the tests now\" â is a coordination protocol you now own, built out of the least reliable component you have: one model trying to communicate intent to another model.\n\n> The number of agents in your system is not a measure of its sophistication. It's a measure of how many places your context c
1an fracture.\n\n## What a second agent actually costs\n\nLet's talk about the bill, because it's the part the impressive diagrams leave out. Anthropic published numbers from their own multi-agent research system, and they're worth sitting with: a single agent burns roughly **4\xd7 the tokens** of a normal chat, and a multi-agent system burns about **15\xd7**. That's not a rounding error. That's your token budget, tripled and tripled again, in exchange for coordination overhead.\n\nSometimes that trade is worth it. Anthropic's own finding is that multi-agent setups genuinely win at *breadth-first* research â questions where you can fan out into many independent directions at once, each subagent exploring its own slice with its own context window, then compressing what it found. If the task is \"find every board member across these 400 companies,\" parallel agents crush a single agent that has to plod through sequentially. That's a real, honest use case.\n\nBut look closely at what makes it work: the subtasks are *independent*. One agent's findings don't change what another agent should do. That's the property that makes parallelism safe. And it's exactly the property most of our work doesn't have.\n\n## Coding is the wrong shape for a swarm\n\nAnthropic say it plainly, and it matches everything I see: most coding tasks involve *fewer* truly parallelizable subtasks than research, and today's models aren't good at coordinating and delegating to each other in real time. Code is a web of dependencies. The change you make in one file constrains the change in the next. Split that across agents and you don't get parallelism â you get two agents making conflicting assumptions about the same system.\n\nThe team behind Devin wrote a whole piece called \"Don't Build Multi-Agents,\" and their core point is sharper than the cost argument. Every action an agent takes carries an *implicit decision*. When you split work across parallel subagents, each one makes its own implicit decisions, and those decisions quietly conflict. Their example is perfect: ask two subagents to build a \"Flappy Bird clone\" in parallel, and one builds a Super Mario background while the other builds a bird that moves nothing like Flappy Bird. Now your final agent has to reconcile two subtly incompatible worlds. Nobody was wrong on purpose. The context just couldn't be shared thoroughly enough for them to agree.\n\n> Actions carry implicit decisions, and conflicting decisions carry bad results.\n\nThat's why even Claude Code, when it spawns a subtask, usually only asks it to *answer a question* â never to go off and write code in parallel. The subagent investigates, reports back, and the main agent keeps making the actual decisions in one continuous line of context. It's a deliberately simple design, and the simplicity is the point.\n\n## The default should be one continuous context\n\nStrip away the fashion and the reliable pattern underneath is almost boringly plain: a single agent, working in one continuous context, making decisions in sequence. It sees everything it did earlier because it never handed off. There's no telephone game, no reconciliation step, no second model guessing at the first one's intent.\n\nYes, you eventually hit a ceiling â a task so large the context window overflows. But the answer to that is rarely \"spawn a committee.\" It's better context management: summarizing completed phases, storing decisions in memory, spinning up a *fresh* context with a clean handoff when you truly need one. You reach for that when the single agent stops fitting, not before â and even then you're managing context, not orchestrating a crowd.\n\n## The real lever isn't more agents\n\nHere's what actually moved the needle for me, and it wasn't a second agent. It was teaching the *one* agent my workflow properly and giving it the right tools. I wired in the MCP servers it needs â reading Jira, Confluence, docs, Postman â so it works with real context instead of guessing. And I invested the months it took to teach it how I actually work, step by step.\n\nThe payoff is that I can now start a fresh session and type \"take task 123 and do it as usual,\" and it knows what that means: assign the ticket to me, move it to In Progress, pull it into the sprint if it's sitting in the backlog, estimate it if it has no points, branch off an updated main by our convention, commit with human-readable messages, open the PR with a real description and the right people tagged, link it to the ticket, and watch the pipeline â fixing it if it goes red. One agent. One phrase. No orchestra.\n\nThat's a whole article of its own â [how I taught one agent to \"do it as usual\"](/blog/agent-playbook-do-it-as-usual) â and it's the honest alternative to the swarm. The effort I *didn't* spend maintaining seven setups, I spent deepening one.\n\n## The reframe\n\nThe multi-agent orchestra is seductive because it looks like engineering. Boxes, arrows, roles, a system. But most of the time it's complexity cosplaying as capability â three times the token bill, a coordination protocol built on the flakiest possible substrate, and results that are *worse* because the context keeps fracturing between the players.\n\nSo my rule is simple: reach for the second agent only when you can name the work it's doing â work that is genuinely independent, genuinely parallel, and genuinely worth 15\xd7 the tokens. Research that fans out? Maybe. Almost everything else? One agent, one context, taught well. The sophistication was never in the number of agents. It was always in how much of your actual work you managed to hand off cleanly to a
1single one.\n\nIf you're running a multi-agent setup right now, do one honest audit: for each agent past the first, write down the independent, parallel task it owns and what it costs you. If you can't name that task without hand-waving, you don't have an architecture â you have a diagram. Tell me which agent you'd delete first.\n"},{id:"react-playbook-code-splitting",title:"Code-Splitting Is a Boundary Decision, Not a Bundle Trick",excerpt:"Every performance guide tells you to lazy-load. Almost none tell you where to stop. Split on your file tree and you trade one big bundle for a hundred tiny chunks and a waterfall of spinners. The real question isn't how to split â it's finding the seams in the user's journey and cutting there.",tags:["React","Architecture","Performance","React Playbook"],date:"Aug 04, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-code-splitting/cover.png",published:!0,content:"\nThe [command palette article](/blog/react-playbook-command-palette) closed by promising the last piece of this look-and-feel stretch, and it's the one that looks the most like a solved problem: code-splitting. Wrap a component in `React.lazy`, drop a `Suspense` boundary around it, watch the bundle shrink. Done, right?\n\nHere's the thing that bugs me about most bundle-size advice. It's all about the *how* â `lazy`, dynamic `import()`, the router's lazy routes â and almost none of it is about the *where*. And the where is the entire problem. Splitting code is one line. Deciding what to cut, so the app loads fast without shattering into a hundred chunks you can't reason about, is an architecture decision. Get the how right and the where wrong, and you don't get a faster app. You get a slower one with more moving parts.\n\n## A split is not free\n\nStart with what a split actually costs, because the mental model everyone skips is that lazy-loading isn't \"free performance.\" Every split you introduce is a boundary, and every boundary buys you three things you didn't have before: a separate network request, a loading state you now have to design, and the risk of a waterfall if that request can't start until another one finishes.\n\n> A code-split isn't a size optimization. It's a boundary you're adding to the app â and boundaries have a cost on both sides.\n\nSo the real question stops being \"can I split this?\" (you almost always can) and becomes \"is there a real seam here worth paying a boundary for?\" That reframe is the whole article.\n\n## The default that quietly backfires\n\nThe advice you'll read first is \"split at the route level,\" and honestly, that advice is correct. Route boundaries are the best seams in the entire app: the user is already waiting for a navigation, they don't expect the next screen to be instant, and each route pulls in a genuinely different slice of the app. With a type-safe router like the one from the [routing article](/blog/react-playbook-tanstack-router), lazy routes are the natural unit:\n\n```tsx\n// each route is its own chunk â the user is already navigating, a boundary here is invisible\nexport const Route = createFileRoute('/reports')({\n component: lazyRouteComponent(() => import('./ReportsPage')),\n});\n```\n\nWhere it backfires is the next step people take. Route-level splitting works, so they conclude \"more splitting is more better\" and start wrapping individual components â a `UserAvatar` here, a `Badge` there â in `lazy`. Now every one of those is a network round-trip and a spinner. You've turned a single fast download into a cascade of tiny requests, each with its own loading flicker, and the page assembles itself in front of the user like a slideshow. That's not a faster app. That's the same bytes, delivered worse.\n\n## Where the seams actually are\n\nSo if \"everywhere\" is wrong and \"routes\" is the safe default, what else genuinely earns a split? Three shapes, and they all have the same property: the code is *heavy* and *not needed for the first meaningful paint*.\n\n**Heavy, rarely-used features.** A rich text editor, a video player, a PDF viewer, the [chart library](/blog/react-playbook-chart-libraries) you only render on the analytics tab. These
1can each be hundreds of kilobytes, and most sessions never touch them. Splitting here is pure win â the cost of the boundary is paid only by the users who actually open the feature.\n\n```tsx\n// the editor is 300kb of the bundle and only the 5% who click \"edit\" ever need it\nconst RichTextEditor = lazy(() => import('./RichTextEditor'));\n\nfunction Note({ isEditing }: { isEditing: boolean }) {\n if (!isEditing) return <NotePreview />;\n return (\n <Suspense fallback={<EditorSkeleton />}>\n <RichTextEditor />\n </Suspense>\n );\n}\n```\n\n**Behind an interaction.** Anything that only appears after a click â a settings modal, a command palette, an export dialog. The boundary hides behind the click the user already made, so the tiny load feels like part of the action, not a delay.\n\n**Below the fold, and truly optional.** The stuff a user might scroll to but often won't.\n\nNotice what's *not* on this list: anything needed for the first render, anything small, and anything that's split purely because it lives in its own folder. A 4kb component doesn't deserve a network request. The download of that request costs more than the bytes you saved.\n\n## The waterfall you'll create by accident\n\nThe nastiest failure mode isn't too many chunks â it's chunks that can't load in parallel. This is the same waterfall problem the [data-fetching article](/blog/react-playbook-data-fetching) warned about, just moved from data to code. A lazy component that, once loaded, immediately lazy-loads its own child, which lazy-loads *its* child â the browser can't start request two until request one lands. You've serialized what should have been parallel.\n\nThe fix is intent-based prefetching: start fetching the chunk when the user *signals* they're heading somewhere, not when they arrive. Hovering a link, focusing a button, opening the parent â all of these are the browser's chance to fetch ahead:\n\n```tsx\n// kick off the chunk on hover, so by the time they click it's already in cache\n<Link\n to=\"/reports\"\n onMouseEnter={() => import('./ReportsPage')}\n>\n Reports\n</Link>\n```\n\nDo this and the boundary all but disappears: the request overlaps with the user's own reaction time instead of stacking behind another request. Good routers do a version of this for you. The point is that *when* a chunk starts loading matters as much as whether you split it.\n\n## The chunk you can't reason about\n\nHere's where splitting collides with the rest of the Playbook. Remember the [barrel-file article](/blog/react-playbook-barrel-files) â the `index.ts` that re-exports everything in a folder? Lazy-load a single component *through* a barrel, and you don't get that component. You get the whole barrel: every sibling it re-exports, dragged into the chunk because the import graph can't tell them apart. Your carefully-placed `lazy` boundary quietly pulls in half the folder, and the chunk you thought was small isn't.\n\n```ts\n// looks like one small component...\nconst Chart = lazy(() => import('./widgets').then((m) => ({ default: m.Chart })));\n// ...but ./widgets/index.ts re-exports Chart, Table, Map, Calendar â all now in this chunk\n```\n\nThis is why I keep coming back to the idea that these topics aren't separate. A split is only as clean as the [module boundaries](/blog/react-playbook-module-dependencies) underneath it. If your imports reach sideways through barrels and shared grab-bags, your chunks will be blurry no matter how many `lazy` calls you sprinkle in. Import the file directly, and the split is exactly as big as you think it is.\n\n## Split where it hurts, not where the folders are\n\nThe [re-renders article](/blog/react-playbook-rerenders-and-architecture) made an argument I want to borrow directly: optimize where it actually hurts, and don't let the optimization wreck the structure. Code-splitting is the same discipline one layer down. The temptation is to split on the shape of your file tree â one chunk per feature folder, because it's tidy. But the file tree is your convenience, not the user's journey. The seams that matter are the ones in *their* experience: the route they navigate to, the heavy tool they occasionally open, the dialog behind a click.\n\nBefore adding a `lazy`, I ask two questions. Is this code actually heavy enough that its bytes show up in the load? And is there a real moment in the user's flow where paying for a separate request is invisible? If both answers aren't yes, the split is making the app more complex for a number that doesn't move.\n\n## The reframe\n\nCode-splitting reads like a bundle trick and behaves like an architecture decision â which makes it a fitting end to this look-and-feel cluster, because so did theming, and so did the command palette. Each one looks like polish and turns out to be a boundary in disguise. The visible artifact is trivial: a smaller bundle, a color toggle, a search modal. The value, every time, is in where you draw the line and whether the rest of the structure lets that line stay clean.\n\nSo the honest heuristic isn't \"split more.\" It's split *rarely and deliberately*, at the seams the user can feel, over module boundaries clean enough that a chunk means what its name says. Everything else is just trading one problem you can see â a big bundle â for a dozen you can't.\n\nIf you've got a build with more than a handful of chunks, pull up your bundle analyzer and find the smallest lazy chunk you have. If it's a few kilobytes, that's a boun
1dary you're paying for and getting nothing back â tell me what it is, because I'd bet it snuck in on the \"more splitting is better\" reflex, and it's the first thing I'd delete.\n"},{id:"react-playbook-command-palette",title:"The Command Palette Is an Architecture, Not a Widget",excerpt:"Cmd+K looks like a fuzzy-search modal you bolt on at the end. Build it that way and it immediately drifts out of sync with the buttons and menus it duplicates. The real thing is a command registry: one typed source of truth for everything the app can do, feeding the palette, the shortcuts, and the menus at once.",tags:["React","Architecture","React Playbook"],date:"Aug 02, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-command-palette/cover.png",published:!0,content:'\nThe [theming article](/blog/react-playbook-theming-dark-mode) ended on a move from how an app looks to how power users drive it. The command palette â the `Cmd+K` menu that fuzzy-searches everything you can do â is the poster child for a piece of UI that people build as a widget and should build as an architecture. The difference shows up fast, and it\'s the difference between a palette that stays useful and one that quietly lies about what the app can do.\n\nHere\'s the trap. You install a nice palette component, drop a modal in, and fill it with a hardcoded list of actions: "New file," "Toggle dark mode," "Go to settings." It demos beautifully. Then someone adds a feature with its own toolbar button, forgets to also add it to the palette, and now the palette is missing things. Someone renames an action in a menu but not in the palette. Six months in, the palette is a stale, hand-maintained duplicate of logic that already exists elsewhere in the app. The modal was never the hard part. The hard part is that the palette needs to know everything the app can do â and so do the menus, and the keyboard shortcuts, and the toolbar.\n\n## A command is a first-class object\n\nThe reframe is to stop thinking about a search modal and start thinking about *commands* as data. A command is a real object: an id, a title, some keywords, the thing it does, and optionally whether it\'s currently allowed and what shortcut triggers it.\n\n```ts\ninterface Command {\n id: string;\n title: string;\n keywords?: string[]; // for fuzzy search: "theme", "appearance"...\n group?: string; // "File", "View", "Navigation"\n shortcut?: string; // "mod+k"\n isEnabled?: () => boolean; // context-aware availability\n run: () => void; // the actual behavior\n}\n```\n\nOnce a command is data, the interesting shift happens: the palette stops *owning* actions and starts *rendering* them. This is the same instinct as [headless components](/blog/react-playbook-headless-components) â separate the behavior (what the command does) from the presentation (how it\'s listed) â and the same instinct as the [typed event catalog](/blog/react-playbook-analytics-as-a-layer) â one declared source of truth instead of scattered strings.\n\n## The registry is the single source of truth\n\nThe center of the whole design is a registry: one place every command is registered, and every surface that needs to know "what can the app do?" reads from it.\n\n```ts\nclass CommandRegistry {\n private commands = new Map<string, Command>();\n\n register(command: Command): () => void {\n this.commands.set(command.id, command);\n return () => this.commands.delete(command.id); // features clean up their own\n }\n\n all(): Command[] {\n return [...this.commands.values()].filter((c) => c.isEnabled?.() ?? true);\n }\n\n run(id: string) {\n this.commands.get(id)?.run();\n }\n}\n\nexport const commands = new CommandRegistry();\n```\n\nNow features *contribute* commands instead of the palette knowing about features:\n\n```ts\n// in the editor feature â it announces what it can do, and to whom it doesn\'t care\ncommands.register({\n id: \'file.new\',\n title: \'New File\',\n group: \'File\',\n shortcut: \'mod+n\',\n run: () => editor.createFile(),\n});\n```\n\nNotice the direction of the dependency. The editor feature depends on the registry (a stable, shared thing); the palette depends on the registry; the two features never depend on each other. That\'s the [dependencies-p
1oint-the-right-way](/blog/react-playbook-module-dependencies) rule holding: everything points at a shared abstraction, nothing reaches sideways. Add a feature, it registers its commands, and the palette shows them with zero changes to the palette. Remove the feature, its cleanup runs, the commands vanish. Nothing drifts because nothing was duplicated.\n\n## One dispatcher, not scattered key handlers\n\nThe keyboard shortcuts fall out of the same registry, and this is where you avoid another mess. The naive approach sprinkles `onKeyDown` handlers across a dozen components, each checking for its own combo â the same scatter problem the [event bus](/blog/react-playbook-event-bus) and analytics articles warned about, just with keystrokes. Instead, one global listener resolves keys against the registry:\n\n```ts\nuseEffect(() => {\n const onKeyDown = (e: KeyboardEvent) => {\n const combo = toCombo(e); // "mod+n", "mod+k", etc.\n const command = commands.all().find((c) => c.shortcut === combo);\n if (command) {\n e.preventDefault();\n command.run();\n }\n };\n window.addEventListener(\'keydown\', onKeyDown);\n return () => window.removeEventListener(\'keydown\', onKeyDown);\n}, []);\n```\n\nOne place owns the mapping from keys to commands. Shortcut conflicts become detectable (two commands claiming `mod+k` is now a thing you can check for, instead of a mystery). And because `isEnabled` gates `all()`, a shortcut simply does nothing when its command isn\'t valid in the current context â no scattered guard clauses.\n\n## The palette is just a view over the registry\n\nAnd now the part everyone thought was the whole feature is the easy part. The palette is a thin view: read the registry, fuzzy-match the query against titles and keywords, render the list, run the chosen command.\n\n```tsx\nfunction CommandPalette() {\n const [query, setQuery] = useState(\'\');\n const results = useMemo(\n () => fuzzyFilter(commands.all(), query), // search titles + keywords\n [query],\n );\n return (\n <Dialog>\n <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Type a commandâ¦" />\n <ul>\n {results.map((c) => (\n <li key={c.id} onClick={() => { commands.run(c.id); }}>\n {c.title}\n {c.shortcut && <kbd>{c.shortcut}</kbd>}\n </li>\n ))}\n </ul>\n </Dialog>\n );\n}\n```\n\nBecause commands are data, you get things for free that would be painful to hand-maintain: search across everything, a "keyboard shortcuts" help screen (just render the registry grouped by `group`), and an honest answer to "what can I do here?" that\'s always current because it\'s generated, never authored twice.\n\n## The reframe\n\nThe command palette is a systems problem wearing a UI costume â the same shape as theming. The visible artifact (a search modal, a color toggle) is trivial; the value is entirely in the architecture behind it (a command registry, a token system). Build either one as a widget and it rots, because you end up maintaining the same truth in several places and they drift apart. Build it as a single source of truth that many surfaces read from, and the palette, the menus, and the shortcuts can never disagree â because there\'s only one thing for them to agree with.\n\nThat\'s the through-line of this whole look-and-feel stretch: the parts of an app that seem like polish are usually boundaries in disguise, and the teams that treat them as boundaries end up with UI that scales instead of UI that quietly lies. Next the series turns to the last piece of this cluster â code-splitting, and the deceptively hard question of what to actually cut so the app loads fast without fracturing into a thousand lazy chunks you can\'t reason about.\n\nIf your app has a command palette, open it and then open your main menu side by side. If they don\'t list the same capabilities, you\'ve got two sources of truth drifting apart â tell me how far they\'ve diverged, because that gap is the whole argument for a registry.\n'},{id:"react-playbook-theming-dark-mode",title:"Theming and Dark Mode Without the Flash: A Systems Problem in CSS Clothing",excerpt:"Dark mode looks like a class you toggle. Then you meet the flash of the wrong theme on load, colors that drift because every component hardcodes its own, and a theme context that re-renders the whole app. Theming done right is tokens, one switch point, and a boun
1dary â and it barely touches React at all.",tags:["React","CSS","Architecture","React Playbook"],date:"Jul 31, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-theming-dark-mode/cover.png",published:!0,content:"\nThe [last article](/blog/react-playbook-rerenders-and-architecture) argued that a lot of React \"optimization\" is really architecture damage in disguise. Theming is a great place to keep that lens on, because it's the feature that looks the most like a trivial CSS toggle and is actually a small systems-design problem â one where the naive version fails in three specific, visible ways.\n\nYou start simple. A `dark` class on the body, some overrides, a bit of React state to flip it. Ship it. Then the reports come in. The page flashes white before going dark on every load. A few components didn't get the memo and stayed light. And your theme toggle mysteriously re-renders half the app. None of these is a CSS bug exactly â they're all consequences of putting theming in the wrong layer.\n\n## Tokens are the foundation, not the paint\n\nThe first mistake is letting every component decide its own colors. `color: #1a1a1a` here, `background: #fff` there, a slightly different grey in a third place. Now \"dark mode\" means hunting down every hardcoded color in the codebase, and \"the greys drifted\" is inevitable because there was never one grey to begin with.\n\nThe fix is design tokens expressed as CSS custom properties: one place that owns the actual color decisions, and components that only ever refer to them *semantically*.\n\n```css\n:root {\n /* primitive palette â raw values, referenced by nobody directly */\n --gray-50: #f9fafb;\n --gray-900: #111827;\n --green-500: #22c55e;\n\n /* semantic tokens â what components actually use */\n --color-surface: var(--gray-50);\n --color-text: var(--gray-900);\n --color-accent: var(--green-500);\n}\n\n[data-theme='dark'] {\n --color-surface: var(--gray-900);\n --color-text: var(--gray-50);\n --color-accent: var(--green-500); /* accent survives the theme */\n}\n```\n\nThe two-layer split matters more than it looks. Primitives (`--gray-900`) are the paint box; semantic tokens (`--color-surface`) are the meaning. Components consume only the semantic layer â `background: var(--color-surface)` â so they never know or care what actual color that is in a given theme. Switching themes is now just swapping what the semantic tokens point to. And critically, a component that says `var(--color-surface)` cannot drift, because there's exactly one definition of surface per theme. This is the same \"one source of truth, referenced by many\" idea from [data normalization](/blog/react-playbook-data-normalization), applied to color.\n\n## The switch doesn't belong in React state\n\nHere's the part that surprises people, and it ties straight back to the last article. Flipping the theme should not be a React re-render at all. You change one attribute on the root element â `data-theme=\"dark\"` â and the CSS cascade does the entire rest of the work, instantly, for every element on the page, with zero components re-rendering.\n\n```ts\nfunction applyTheme(theme: 'light' | 'dark') {\n document.documentElement.setAttribute('data-theme', theme);\n localStorage.setItem('theme', theme); // persist, as a side effect\n}\n```\n\nContrast that with the common mistake: putting the theme in a React context that wraps the whole app, so that toggling it re-renders every consumer to pass down new colors. That's taking a job CSS does for free â cascading a variable change to the whole tree â and turning it into a render storm, the exact anti-pattern from the [re-render article](/blog/react-playbook-rerenders-and-architecture). Let CSS variables do what they're built for. You can still keep a *small* piece of React state for the toggle's UI (which icon to show), but the actual theming mechanism is a DOM attribute and a cascade, not a re-render.\n\n## The flash is a rendering-order problem\n\nNow the ugliest one: the flash of the wrong theme (FOUC). On a statically exported, server-rendered site â which is [exactly this stack](/blog/nextjs-ssg-github-pages) â the server has no idea what theme the user picked. It renders the default, ships the HTML, the browser paints it, and only *then* does your React code boot, read localStorage, and correct the theme. That gap between first paint and correction is the flash, and no amount of React can remove it, because React runs too late by definition.\n\nThe fix has to happen *before* first paint, which means a tiny blocking script in the document `<head>`, before any content renders:\n\n```tsx\n// injected into <head>, runs synchronously before the body paints\nconst themeScript = `\n (function () {\n try {\n var stored = localStorage.getItem('theme');\n var theme = stored || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');\n document.documentElement.setAttribute('data-theme', theme);\n } catch (e) {}\n })();\n`;\n\n// in the App Router root layout\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang=\"en\" suppressHydrationWarning>\n <head>\n <script dangerouslySetInnerHTML={{ __html: themeScript }} />\n </head>\n <body>{children}</body>\n </html>\n );\n}\n```\n\nIt feels a little dirty to hand-inject a script, but it's the correct tool: it sets `data-theme` on `<html>` synchronously, before the browser paints a single pixel, so the very first frame is already the right theme. This is also the disciplined version of the [localStorage lesson](/blog/react-playbook-local-storage-and-state) â read persisted state in the right place at the right time, not during React's render where it causes hydration mismatches. Note `suppressHydrationWarning` on `<html>`: the attribute the script sets won't match the server's markup, and that's expected, so we tell React not to complain about this one specific, intentional divergence.\n\n## Respect the system, but let people override\n\nA complete theming system has three inputs, in priority order: the user's explicit choice, then the OS preference, then a default. `prefers-color-scheme` gives you the OS signal, and `matchMedia` lets you react to it changing live (someone flips their laptop to dark at sunset):\n\n```ts\nconst media = matchMedia('(prefers-color-scheme: dark)');\nmedia.addEventListener('change', (e) => {\n // only follow the system if the user hasn't set an explicit preference\n if (!localStorage.getItem('theme')) {\n applyTheme(e.matches ? 'dark' : 'light');\n }\n});\n```\n\nThe rule that keeps this sane: an explicit user choice always wins and always persists; the system preference is only the fallback when the user hasn't decided. Conflating those two â following the OS even after the user picked something â is how you get the maddening bug where a user's chosen theme keeps getting overridden.\n\n## The reframe\n\nTheming teaches the same lesson this stretch of the series keeps circling: the thing that looks like a surface detail is usually a boun
1dary decision. Where do color decisions live? (In tokens, once.) What actually performs the switch? (A DOM attribute and the cascade, not React.) When does the theme get resolved? (Before first paint, not after hydration.) Get those three boundaries right and dark mode is almost anticlimactic â a few dozen lines, no flash, no drift, no render storm. Get them wrong and you're chasing flashes and mismatched components forever, patching symptoms of a structure that put theming in the wrong place.\n\nNext, the series stays on architectural UI but moves from how the app *looks* to how power users *drive* it. The command palette â that `Cmd+K` menu â looks like a search modal and is really a command registry: one source of truth for everything the app can do, feeding the palette, the menus, and the keyboard shortcuts at once. That's where we go.\n\nIf your app has dark mode, do the two-second test: hard-refresh it a dozen times on a throttled connection and watch the first frame. If you catch a flash of the wrong theme, your theming is resolving too late â and I'd love to hear whether it was the flash, the drift, or the render storm that got you first.\n"},{id:"react-playbook-rerenders-and-architecture",title:"Re-Renders Are React's Superpower, Not a Disease to Cure",excerpt:"There's a particular panic I keep running into: a component re-rendered, someone saw it in the profiler, and now they're tearing the architecture apart to stop it. But rendering isn't React's flaw â it's the whole idea. The word you want is optimize, not minimize, and the difference decides whether your app stays maintainable.",tags:["React","Architecture","Performance","React Playbook"],date:"Jul 29, 2026",readTime:"13 min read",coverImage:"/blog/react-playbook-rerenders-and-architecture/cover.png",published:!0,content:"\nI was going to move the series straight into how an app looks and feels. But something has been bugging me for a while, and it's architectural enough that skipping it would be dishonest. So this is a short detour, and it's about a panic I keep seeing: someone opens the React Profiler, watches a component re-render when they didn't expect it to, and immediately starts demolishing their code to make the re-render stop. Props get removed. Components get split in strange places. `memo` gets sprayed over everything. All to drive a number down.\n\nHere's the thing I want to say plainly, because almost nobody says it: **re-rendering is not React's weakness. It's React's superpower.** And a lot of the \"optimizations\" people perform to avoid re-renders are quietly worse for the codebase than the re-renders ever were.\n\n## Rendering *is* the model\n\nStep back and remember what React actually is. Your UI is a function of state. State changes, React re-runs the function, produces a new description of the UI, and reconciles it against the last one. That re-run â the render â is not an accident or an overhead bolted onto React. It *is* React. The entire programming model, the reason React felt like a relief after manual DOM manipulation, is that you get to describe \"what the UI looks like for this state\" and never wire up the updates yourself.\n\nCompare it to a signals-based library â Solid, or the fine-grained-reactivity family. There the model is different by design:\n\n```tsx\n// Solid-style: fine-grained. The component function runs ONCE.\nfunction Counter() {\n const [count, setCount] = createSignal(0);\n // this text node subscribes to count directly; only IT updates\n return <button onClick={() => setCount(count() + 1)}>{count()}</button>;\n}\n```\n\n```tsx\n// React: the component function re-runs on every state change.\nfunction Counter() {\n const [count, setCount] = useState(0);\n // the whole function runs again; React diffs the result\n return <button onClick={() => setCount(count + 1)}>{count}</button>;\n}\n```\n\nBoth are legitimate architectures. Signals update surgically and never re-run the component; React re-runs the component and diffs. But notice: the React version's \"re-run everything and figure out what changed\" is the very thing that makes React's mental model so simple. You don't track dependencies by hand. You don't wire subscriptions. You describe UI-as-a-function-of-state and let the render do the work. Treating that render as a defect is like buying a bird and being annoyed that it keeps flying. The flying is the point.\n\n> A re-render is not React doing something wrong. It's React doing the one thing it exists to do. The goal was never zero renders â it was the freedom to stop thinking about updates.\
1n\n## So what's actually bad, then?\n\nI'm not saying render count never matters. It does â but the problem is narrower than the panic suggests. A bad situation is a *render storm at scale*: every mousemove re-rendering every component on the page, a giant list re-rendering all thousand rows because one row's checkbox changed, an expensive tree re-running on every keystroke in an unrelated input. That's real, and it's worth fixing.\n\nBut a component re-rendering because its parent re-rendered, doing a bit of cheap work and producing the same DOM? That's usually nothing. React is *fast* at this. The reconciler is built for it. Most of the re-renders people agonize over cost less than the `useMemo` they add to prevent them. The panic treats every render as a cost when the real question is only ever \"is this render actually expensive, and does it actually happen often enough to matter?\"\n\nWhich is why the word matters so much. People say they want to *minimize* re-renders. The word you actually want is **optimize**. Minimize is a religion â zero renders at any cost. Optimize means find the balance: spend effort on the renders that hurt, and leave the cheap ones alone. Minimizing is something you do to a number. Optimizing is something you do to a system, with judgment about where the cost really is.\n\n## The trade nobody prices: architecture for render count\n\nHere's where this becomes an architecture article and not a performance tip, because the real damage isn't the wasted effort â it's what people *break* to save a render. Let me give you a few shapes I keep seeing.\n\n**Contorting to remove a \"guilty\" prop.** A component re-renders because a prop changes. So instead of leaving the honest data flow in place, someone restructures so the component no longer *receives* that prop â it reaches into a store or context to grab the value itself, so the parent can't \"trigger\" it. The render is gone. But now the component's real dependency is hidden. It looks independent and isn't. You've taken a visible prop â a traceable edge in your dependency graph â and turned it into an invisible reach-out, the exact anti-pattern the [composition and DI article](/blog/react-playbook-composition-and-di) argued against. You optimized a render and de-optimized your ability to understand the code.\n\n**Memo everywhere as a reflex.** Every component wrapped in `memo`, every value in `useMemo`, every function in `useCallback` â not because anything was measured, but because renders are scary now.\n\n```tsx\n// this \"optimization\" costs more than it saves\nconst Row = memo(function Row({ item, onSelect }: RowProps) {\n const handle = useCallback(() => onSelect(item.id), [onSelect, item.id]);\n const style = useMemo(() => ({ paddingLeft: item.depth * 8 }), [item.depth]);\n return <div style={style} onClick={handle}>{item.label}</div>;\n});\n```\n\nFor a trivial row, the memoization machinery â the comparison, the dependency arrays, the mental overhead â is more expensive than just rendering the div. And every one of those dependency arrays is now a thing that can go subtly wrong and a thing the next person has to maintain. You bought a micro-optimization with a permanent tax on changeability.\n\n**Splitting on render boundaries instead of responsibility boundaries.** This is the worst one, because it looks like good decomposition. To isolate a re-render, someone fractures a cohesive component into oddly-shaped pieces â not because those pieces are meaningful units, but because the split happens to stop a render from propagating. Now a single logical concern is smeared across three components that only make sense together, which is precisely the [smeared-component](/blog/smeared-component) problem wearing a performance badge. You decomposed for the profiler, not for the reader.\n\nIn each case the pattern is identical: a local win on render count, paid for with a structural loss that the profiler will never show you.\n\n## The part everyone forgets: the component has to be *maintained*\n\nThe fixation on renders has tunnel vision. It optimizes for one moment â the render â and forgets that this component has a whole life ahead of it. It's going to grow. It's going to be reused somewhere the \"clever\" structure doesn't fit. Someone's going to need to change it in six months. The \"ideal\" low-render component is very often a maintenance liability, because its shape was dictated by a performance metric instead of by what the thing actually *is*.\n\nThis is where the balance genuinely lives, and it's context-dependent. Is this a marketing landing page? Then honestly, do nothing â render \"waste\" on a page that mounts once and barely updates is a non-problem, and every hour spent memoizing it is
1wasted. But a large, high-load, long-lived application â the kind with a real [model / view-model / view separation](/blog/react-playbook-container-presentational), data fetching, aggregation, and preparation layers â *that's* where the trade-off between architecture and render performance is a real engineering decision. And the answer there is deliberate: you optimize the hot paths you've measured, and you refuse to sacrifice the structure that keeps the app maintainable for renders that don't hurt.\n\n## The screw you can't reach\n\nI was reassembling my daughter's computer recently and it made this click for me. Ten screws on the back, off comes the panel, and everything inside is modular â the drive, the memory, the fan all lift out independently. Fifteen minutes to clean it. I remembered the old laptops I used to take apart years ago, where you had to remove things in one exact sequence, and if you wanted to reach one component you first had to dismantle four unrelated ones sitting on top of it. Miss the order and you're stuck.\n\nBad render-driven architecture is the old laptop. To save a render you bury a \"screw\" â you tuck a dependency somewhere awkward, you weld two concerns together, you hide a prop â and later, when you need to change that part, you can't get to it without taking apart everything piled on top. Good architecture is my daughter's computer: parts have obvious places, and when you want to move, replace, or fix one, you just can. The whole reason we care about structure is that day *later*, when requirements change and you need to reach in cleanly.\n\nThat serviceability is worth far more than a render count, and it's exactly what people trade away when the profiler scares them.\n\n## Where I land\n\nSo my actual rule is unglamorous. Treat re-rendering as the normal, healthy heartbeat of a React app, not a symptom. Measure before you optimize â find the renders that are genuinely expensive and genuinely frequent, and fix *those*, with the smallest intervention that works. And never, ever trade the reachability of your architecture for a number in a profiler, because one of those you can feel every day you maintain the code, and the other you mostly imagined.\n\nRenders are React's superpower. Point it where it helps, tune it where it hurts, and stop apologizing for the thing that makes the whole model work.\n\nWith that off my chest, the series gets back on track â into how an app *looks and feels* at an architectural level, starting with the one that looks like a CSS detail and is really a systems problem: theming and dark mode, done so it doesn't flash on load, doesn't drift across components, and doesn't fight server rendering. That's next.\n\nIf you've got a component in your app that was reshaped purely to cut a re-render, go look at it now and ask an honest question: was the render actually expensive, or did you just make the code harder to change to satisfy a profiler? Tell me what you find â I suspect the ratio surprises people.\n"},{id:"react-playbook-analytics-as-a-layer",title:"Analytics Is Architecture: Stop Sprinkling track() Everywhere",excerpt:"Most analytics code is a scatter of track('button_click') calls with drifting names, mystery payloads, and three vendor SDKs called inline. Analytics is a cross-cutting concern, and it belongs in a layer with a typed event catalog â not smeared through your components. Here's how I structure it.",tags:["React","Architecture","React Playbook"],date:"Jul 27, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-analytics-as-a-layer/cover.png",published:!0,content:"\nThis cluster has been about how the parts of an app communicate â [event buses](/blog/react-playbook-event-bus) for ambient signals, [feature flags](/blog/react-playbook-feature-flags) for toggling behavior. Both articles kept circling the same lesson: cross-cutting concerns want a typed contract and a single home, not a scatter of stringly-typed calls. Analytics is the purest example of that lesson, and the one almost everyone gets wrong first â myself included, years ago.\n\nHere's what analytics looks like in most codebase
1s I've opened. A `track('button_click')` in one component. An `analytics.logEvent('Button Clicked')` in another. A raw `window.gtag('event', 'cta')` inline in a third because someone was in a hurry. Event names that mean the same thing spelled three ways. Payloads where one place sends `userId` and another sends `user_id` and a third helpfully includes the user's *email* into a third-party vendor. It works, in the sense that events show up somewhere. But it's un-auditable, un-typed, vendor-locked, and a privacy incident waiting to happen.\n\nThat's not a tracking problem. That's an architecture problem.\n\n## Analytics is a cross-cutting concern\n\nAnalytics has the same shape as logging, i18n, and error reporting: it touches almost every part of the app, but it *belongs* to none of them. And the [smeared-component article](/blog/smeared-component) named exactly this failure mode â logic that should live in one layer instead gets spread thin across the whole UI until you can't change it in one place. A `track()` call baked into a button's onClick is analytics logic living in the wrong layer. The button's job is to be a button. Knowing that clicking it should emit a \"checkout started\" event to three vendors is not the button's concern.\n\n> If you can't answer \"what does this app track, and where does that data go?\" by opening one folder, your analytics isn't a layer â it's a rash.\n\nThe fix is the same as every other cross-cutting concern in this series: pull it into a dedicated layer with a clear, typed boundary, and let the rest of the app talk to that boundary instead of to a vendor SDK.\n\n## A typed event catalog\n\nStep one is to stop passing strings and start declaring events, once, with typed payloads. This is the same move that tamed the [event bus](/blog/react-playbook-event-bus): a catalog the compiler knows about.\n\n```ts\n// the single source of truth for everything the app can track\ntype AnalyticsEvents = {\n 'checkout_started': { cartValueCents: number; itemCount: number };\n 'checkout_completed': { orderId: string; amountCents: number };\n 'search_performed': { query: string; resultCount: number };\n 'signup_completed': { plan: 'free' | 'pro' | 'team' };\n};\n\nexport function track<K extends keyof AnalyticsEvents>(\n event: K,\n properties: AnalyticsEvents[K],\n) {\n analyticsBus.emit(event, properties);\n}\n```\n\nNow `track('checkout_started', { cartValueCents: 4200, itemCount: 3 })` autocompletes, the payload is type-checked, and a misspelled event name is a compile error instead of a silently-dropped data point six weeks of dashboards later. `AnalyticsEvents` is the catalog â one file that answers \"what does this product measure?\" That question having a single, readable answer is worth more than any individual event.\n\n## Decouple your code from the vendor\n\nStep two is the part that pays off for years: your product code should emit *domain events*, and a separate adapter should decide what to do with them. Product code says \"checkout started.\" It does not know or care whether that goes to Amplitude, GA4, Segment, a data warehouse, or all four.\n\n```ts\ninterface AnalyticsSink {\n send(event: string, properties: Record<string, unknown>): void;\n}\n\n// each vendor is an adapter â swappable, testable, isolated\nconst amplitudeSink: AnalyticsSink = {\n send: (event, props) => amplitude.track(event, props),\n};\n\n// one subscriber wires the typed catalog to whatever sinks are configured\nexport function initAnalytics(sinks: AnalyticsSink[]) {\n analyticsBus.onAny((event, properties) => {\n for (const sink of sinks) sink.send(event, properties);\n });\n}\n```\n\nThis is [dependency injection](/blog/react-playbook-composition-and-di) applied to tracking: the product depends on an abstraction (`track`), and the concrete vendor is injected at the edge. Swap Amplitude for Segment by changing one adapter â not by grep-and-replacing three hundred call sites. Add a second vendor without touching a single component. Run tests with a fake sink that just records calls, so you can assert \"checkout_completed fired once with this payload\" without any network. And notice this is a textbook *good* use of the event bus from earlier in the cluster: the emitter (product code) genuinely should not know who's listening (the vendors), so the indirection is real decoupling, not hidden coupling.\n\n## Governance is the whole point, not an afterthought\n\nOnce analytics is a layer, the things that were impossible when it was scattered become easy â and these are the things that actually matter when the company grows up:\n\n- **Naming.** One catalog means one convention. No more `Button Clicked` versus `button_click` versus `btnClick` for the same action.\n- **PII control.** A single choke point is where you enforce \"no emails, no raw tokens, no free-text that might contain personal data.\" You can scrub, allowlist, or type-forbid sensitive fields in *one* place. When legal asks \"are we sending any personal data to this vendor,\" you have a file to point at instead of a codebase to spelunk.\n- **Auditing.** GDPR, CCPA, a privacy review â all of them ask \"what do you collect and where does it go?\" With a catalog and adapters, that's a five-minute answer. Sprinkled `track()` calls make it a five-day investigation.\n\nNone of that governance is possible when tracking is smeared across components. It's *trivial* when tracking flows through one typed layer. That's the real argument for the architecture â not elegance, but the fact that a scattered approach becomes a liability the moment anyone with a compliance question walks in.\n\n## Closing the cluster\n\nThat wraps this run on communication between the parts of an app, and the three pieces rhyme on purpose. Event buses, feature flags, and analytics all start life as innocent one-liners sprinkled through the UI â `emit`, `if (flag)`, `track` â and all three rot the same way: stringly-typed, un-auditable, impossible to change in one place. And all three are fixed the same way: a typed contract, a single home, and a boun
1dary the rest of the app talks to instead of reaching past. Communication between parts done well isn't ambient magic. It's explicit, typed, discoverable contracts â the exact opposite of \"anything can talk to anything.\"\n\nNext, the series turns to a short but overdue detour â the panic over React re-renders, why rendering is the framework's superpower rather than its disease, and the architecture people quietly break trying to avoid it. Right after that it gets into how an app *looks and feels* at an architectural level, starting with theming and dark mode. That's where we go.\n\nIf your app tracks analytics, try the one-folder test right now: can you open a single place and read the full list of what you collect and where it's sent? If the honest answer is \"no, it's everywhere,\" you've found your next refactor â and I'd like to hear how scattered it turned out to be.\n"},{id:"react-playbook-feature-flags",title:"Feature Flags: Every Flag Is a Branch You Promised to Delete",excerpt:"Feature flags decouple deploy from release, and that's genuinely powerful. But every flag is a fork in your code, forks multiply, and the ones nobody deletes quietly become the most dangerous debt in the codebase. The architecture isn't the flag â it's how you evaluate, type, and eventually kill it.",tags:["React","Architecture","React Playbook"],date:"Jul 25, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-feature-flags/cover.png",published:!0,content:"\nThe [event bus article](/blog/react-playbook-event-bus) ended on a warning about a certain kind of switch teams sprinkle everywhere with a casual \"it's just a toggle\" attitude â and then can't get rid of. Feature flags are that switch. They're one of the most genuinely useful tools in a mature frontend, and also one of the fastest ways to accumulate debt that never shows up on a dashboard until it hurts.\n\nLet me be fair to them first, because they earn their place. A flag lets you deploy code without releasing it, roll a feature out to 5% of users and watch, keep a kill switch for something risky, or run an A/B test. Decoupling \"the code is in production\" from \"users can see it\" is a real superpower â it turns scary big-bang launches into calm, reversible dials. I'm not here to talk you out of flags. I'm here to talk about the bill that arrives later.\n\n## The hidden cost: every flag forks your code\n\nHere's what a flag actually is, mechanically:\n\n```tsx\nif (flags.newCheckout) {\n return <NewCheckout />;\n}\nreturn <LegacyCheckout />;\n```\n\nThat's a fork. Your code now has two paths, and *both of them ship to production*. Fine for one flag. But flags don't come alone. Add `newCheckout`, `expressPay`, and `giftingV2`, and you don't have three toggles â you have up to eight combinations of code paths, and your test suite almost certainly covers two of them. The combination where `newCheckout` is on, `expressPay` is off, and `giftingV2` is half-rolled-out is a real state a real user can hit, and nobody has ever seen it.\n\n> Every flag doubles the number of code paths you technically ship. Ten flags is a thousand possible combinations of your app, and you have tested maybe five of them.\n\nThis is the cost that never makes it onto the ticket. The flag went in to *reduce* risk, and each one individually does. But their product quietly grows a combinatorial space of untested app states, and that space is where the weird, unreproducible bugs live.\n\n## Not all flags are the same thing\n\nThe biggest conceptual mistake I see is treating \"feature flag\" as one category. It's at least four, and they have completely different lifespans:\n\n- **Release flags** â \"show the new checkout.\" Short-lived. The whole point is to remove them once the feature is fully rolled out. If it's still there three months after 100% rollout, it's dead weight.\n- **Ops / kill switches** â \"disable the recommendation engine if it's melting.\" Long-lived on purpose. These are operational controls, not temporary forks.\n- **Experiment flags** â \"variant A vs B.\" Live exactly as long as the experiment, then die with a decision.\n- **Permission / entitlement flags** â \"this customer's plan includes analytics.\" These aren't really flags at all. They're *configuration* or *authorization*, permanent by nature, and modeling them as feature flags muddies both systems.\
1n\nThe failure is conflating these. A release flag that's secretly treated as permanent config never gets deleted. An entitlement check that's built as a \"flag\" ends up in the same dusty list as abandoned experiments. Name the *kind* of flag when you create it, because the kind determines whether â and when â it's supposed to disappear.\n\n## Dead flags are the real debt\n\nA release flag that's been at 100% for six months isn't neutral. It's a permanent `if` with a dormant `else` branch full of code that no longer runs but still compiles, still ships, still shows up when someone greps the codebase, and still makes every reader pause to figure out whether the old path matters. It's a comment that lies, except the compiler enforces the lie.\n\nSo flags need a *removal* discipline baked in from creation, not bolted on never. The cheapest version that works: give every flag an owner and an expiry, in the definition itself, so a stale flag becomes visible instead of invisible.\n\n```ts\ntype FlagDefinition = {\n key: string;\n kind: 'release' | 'ops' | 'experiment';\n owner: string;\n /** Release/experiment flags carry a date they're expected to be gone by. */\n expires?: string; // ISO date\n description: string;\n};\n\nconst flags: FlagDefinition[] = [\n {\n key: 'newCheckout',\n kind: 'release',\n owner: 'payments',\n expires: '2026-09-01',\n description: 'Rollout of the redesigned checkout flow.',\n },\n];\n```\n\nA tiny CI check that fails when a `release` flag is past its `expires` date turns \"we'll clean it up eventually\" into \"the build reminds us.\" This is exactly the *contract* step from the [changing-shared-components article](/blog/react-playbook-changing-shared-components): a deprecation is a promise to remove the old path, and a flag is a promise to remove one of two branches. Flags that never get contracted are the same debt, wearing a different hat.\n\n## Centralize evaluation behind a typed door\n\nArchitecturally, the thing that makes flags survivable is refusing to scatter raw checks through the codebase. If `if (config.flags['new_checkout'])` appears inline in forty components with forty slightly different string keys, you can neither find all usages nor safely delete the flag. Put evaluation behind one typed interface:\n\n```ts\n// keys are a closed set â typos are compile errors, usages are greppable\ntype FlagKey = 'newCheckout' | 'expressPay' | 'giftingV2';\n\nexport function useFlag(key: FlagKey): boolean {\n const flags = useFlagContext();\n return flags[key] ?? false; // safe default: off\n}\n\n// usage â one obvious, discoverable pattern everywhere\nfunction Checkout() {\n const newCheckout = useFlag('newCheckout');\n return newCheckout ? <NewCheckout /> : <LegacyCheckout />;\n}\n```\n\nOne door means you can list every flag from the `FlagKey` type, find every use of a flag by searching for one function, and delete a flag by deleting its key and chasing the compile errors â the same \"smaller public API is a smaller promise\" idea from the [module boundaries](/blog/react-playbook-module-dependencies) work. One more thing specific to this series' [static-export stack](/blog/nextjs-ssg-github-pages): decide deliberately whether a flag is evaluated at *build time* (baked into the static output, changing it needs a rebuild) or at *runtime* (fetched client-side, changeable live). Build-time flags are simpler and free; runtime flags cost you an evaluation path but let you flip without deploying. Neither is wrong â but conflating them leads to \"why didn't my flag change do anything\" confusion.\n\n## The reframe\n\nFeature flags are a borrowing tool. Each one lets you borrow flexibility now â ship dark, roll out slow, bail out fast â against a promise to pay it back by deleting the branch later. Teams get in trouble not because they use flags, but because they only ever do the borrowing half. The flag goes in, the feature ships, everyone moves on, and the `else` branch sits there forever accruing interest in the form of code paths nobody understands.\n\nSo the discipline is simple to say and hard to keep: know what *kind* of flag you're adding, give the temporary ones an owner and a death date, put them all behind one typed door, and treat deleting a flag as part of shipping the feature, not a someday-chore. A flag system with a removal habit is a superpower. A flag system without one is a slowly growing maze.\n\nThere's one more cross-cutting concern that gets sprinkled through components with the same casual carelessness as flags and events â scattered, stringly-typed, and impossible to audit â and it happens to be a perfect customer for the typed-event thinking from the last two articles: analytics. That's where this cluster ends.\n\n
1If you've got flags in your app, go count how many have been at 100% for more than a quarter. That number is your dead-branch debt, and I'd bet it's higher than anyone on the team would guess. Tell me what you find.\n"},{id:"react-playbook-event-bus",title:"The Event Bus: Great for Decoupling, Great for Making a Mess",excerpt:"An event bus feels like the cure for prop drilling and tangled dependencies â anything can talk to anything, and nothing is wired together. That's exactly the problem. It doesn't remove the coupling, it hides it. Here's where a bus genuinely helps and where it quietly turns your app into action-at-a-distance.",tags:["React","Architecture","React Playbook"],date:"Jul 23, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-event-bus/cover.png",published:!0,content:"\nThe [last cluster](/blog/react-playbook-state-machines) was about making state inside a feature explicit and well-modeled. This one is about what happens *between* features â how the parts of a growing app talk to each other. And the very first tool people reach for is also the one most likely to undo all that hard-won explicitness: the global event bus.\n\nThe pitch is seductive. Component A, buried deep in one part of the tree, needs to tell component B, buried in a completely different part, that something happened. Lifting state up to their common ancestor means threading it through ten components that don't care. Prop drilling. Context that spans half the app. It all feels heavy. So you drop in an event bus â `bus.emit('cartUpdated')` here, `bus.on('cartUpdated', ...)` there â and suddenly A and B are talking without knowing about each other. Decoupled. Clean. Done.\n\nExcept you didn't remove the coupling. You hid it.\n\n## Decoupling versus hiding\n\nThere's a real difference between two components being *decoupled* and two components being *secretly coupled through a channel you can't see*. When A calls a function B gave it, the connection is explicit â you can find it, type it, trace it. When A emits `'cartUpdated'` into a global bus and B happens to listen, the connection still exists â B breaks if A stops emitting â but nothing in the code *shows* it. You can't right-click `emit('cartUpdated')` and find the listeners. You can't tell, six months later, whether anyone still cares about that event or whether you can safely stop firing it.\n\nThis is action-at-a-distance, and it's the exact thing the [dependencies article](/blog/react-playbook-module-dependencies) warned about: the real architecture is the graph of who depends on whom, and a bus makes edges of that graph invisible. Fire-and-forget quietly becomes fire-and-pray.\n\n> An event bus doesn't remove coupling. It converts visible coupling into invisible coupling â and invisible coupling is the kind that rots.\n\nI'm not saying never use one. I'm saying the bus is not a way to *avoid* designing your data flow. It's a specific tool for a specific shape of problem, and using it for the wrong shape is how apps turn into a s\xe9ance where components whisper to each other and nobody knows who's in the room.\n\n## The shape a bus is actually for\n\nHere's my test. A bus is the right tool when the emitter *legitimately should not know* who cares â when the coupling, if you made it explicit, would be *accidental* rather than real. That's a narrow but genuine category:\n\n- **One-to-many, fire-and-forget.** \"The user logged out.\" Auth doesn't know â and shouldn't know â that the cart cache, the websocket connection, and the draft autosaver all need to react. Making auth import and call all three would be a false dependency pointing the wrong way.\n- **Cross-cutting concerns.** Analytics, logging, toasts. A \"payment succeeded\" event that a toast system and an analytics layer both react to â neither of which the payment code should depend on.\n- **Truly ambient signals.** \"Went offline,\" \"theme changed,\" \"session expiring soon\" â things many unrelated parts respond to independently.\n\nNotice the pattern: in every case, wiring it explicitly would force a module to depend on things *beneath* it or *beside* it that it has no business knowing. The bus is right precisely because the alternative is a worse dependency.\n\nAnd here's the flip side â when there's a *real*, traceable data dependency, use the explicit tool. If B needs a value that A owns, that's not an event, that's state: put it in a [store](/blog/react-playbook-reatom-state-management) or pass it down. If A needs B to do something and A legitimately owns that relationship, that's a function call or a callback prop. Don't reach for events to dodge the work of designing where data lives. Using a bus to move owned state around is the single most common way this goes wrong.\n\n## Untyped buses are where the mess compounds\n\nEven for a legitimate use, the default event bus API is a trap: stringly-typed names and `any` payloads.\n\n```ts\n// the mess: no autocomplete, no safety, no discoverability\nbus.emit('user_loggedOut', { usrId: 42 });
1 // typo in event name\nbus.on('user_logged_out', (data) => { // ...silently never fires\n clearCache(data.userId); // payload shape? who knows\n});\n```\n\nTwo different spellings of the same event, a payload field that might be `usrId` or `userId` depending on who wrote which side, and zero help from the compiler. Multiply that across a codebase and the bus becomes a pile of magic strings nobody dares touch. The fix is a typed event map, so the bus knows every event and its payload:\n\n```ts\ntype AppEvents = {\n 'auth/loggedOut': { userId: number };\n 'cart/updated': { itemCount: number };\n 'payment/succeeded': { orderId: string; amountCents: number };\n};\n\nfunction createTypedBus<Events extends Record<string, unknown>>() {\n const listeners = new Map<keyof Events, Set<(payload: never) => void>>();\n return {\n on<K extends keyof Events>(type: K, fn: (payload: Events[K]) => void) {\n const set = listeners.get(type) ?? new Set();\n set.add(fn as (payload: never) => void);\n listeners.set(type, set);\n return () => set.delete(fn as (payload: never) => void); // unsubscribe\n },\n emit<K extends keyof Events>(type: K, payload: Events[K]) {\n listeners.get(type)?.forEach((fn) => (fn as (p: Events[K]) => void)(payload));\n },\n };\n}\n\nexport const bus = createTypedBus<AppEvents>();\n```\n\nNow `bus.emit('auth/loggedOut', { userId: 42 })` autocompletes, the payload is checked, and a typo in the event name is a compile error. Just as importantly, `AppEvents` is a *catalog* â one place that lists every event the app can raise. The bus stops being a fog of strings and becomes a documented contract. (You don't need to hand-roll this; `mitt` and similar libraries take a typed event map. But seeing the shape is the point.)\n\n## Keeping the bus from becoming a god object\n\nThe last discipline: scope it. One global bus that every feature emits into and listens to is just global mutable state wearing a bow tie â the god object the [shared-code article](/blog/react-playbook-shared-code) is allergic to. Prefer buses scoped to a boundary: a feature-level bus for events within a feature, and a small, deliberate set of truly app-wide events at the top. Treat the app-wide event catalog like a public API â small, stable, reviewed when it changes. If your `AppEvents` type has forty entries, that's not decoupling, that's a distributed spaghetti dinner.\n\nBecause here's the thing that took me a while to accept: an event bus doesn't make coupling go away, it makes coupling *a design decision you have to be deliberate about*. Used for the narrow set of genuinely ambient, one-to-many, cross-cutting signals, it's the right tool and nothing else comes close. Used as a general-purpose \"let me talk to that far-away component without wiring anything,\" it reintroduces every bit of implicit, untraceable magic the last five articles worked to remove â just with a nicer-sounding name.\n\nThat framing â a bus is fine, an *undisciplined* bus is the enemy â sets up the next one perfectly, because there's a specific kind of switch that teams love to sprinkle everywhere with exactly the same \"it's just a quick toggle\" energy, and it multiplies your code paths behind your back: the feature flag. That's next.\n\nIf your app has an event bus, do one experiment: try to list every event it carries and who listens to each. If you can't do it from memory or from one file, the bus has already started hiding your architecture from you â tell me how long the list turned out to be.\n"},{id:"react-playbook-state-machines",title:"State Machines in React: When Booleans Start Lying to You",excerpt:"isLoading, isError, isSuccess, isEditing â five booleans give you thirty-two combinations, and most of them are impossible states your code still has to survive. A state machine makes the impossible ones unrepresentable. Here's when to hand-roll one and when to reach for XState.",tags:["React","Architecture","React Playbook"],date:"Jul 21, 2026",readTime:"12 min read",coverImage:"/blog/react-playbook-state-machines/cover.png",published:!0,content:"\nLook at a component you wrote recently and count its status booleans. `isLoading`, `isError`, `isSuccess`, `isEditing`, `isSubmitting`. Five of them. That's not five states â that's two-to-the-fifth, thirty-two combinations, and you only meant for about four of them to exist. What is your UI supposed to do when `isLoading` and `isError` are both true? You never decided, because you never meant for that to happen. But nothing in the code stops it, so one day a fast retry sets both, and you get a spinner layered over an error message, and a bug report you can't reproduce.\n\nThis is the quiet tax of modeling state as loose booleans. Every flag you add doubles the space of possible states, most of the new ones are nonsense, and your code silently becomes responsible for all of them. The [undo/redo article](/blog/react-playbook-undo-redo) got us thinking about changes as explicit, named transitions. A state machine is that same idea applied to the states themselves.\n\n## Impossible states, made impossible\n\nThe core move of a state machine is to stop representing status as independent booleans and start representing it as *one* value that can only be one of a fixed set of named states:\n\n```ts\n// before: 32 representable combinations, ~4 intended\ntype Status = {\n isLoading: boolean;\n isError: boolean;\n isSuccess: boolean;\n};\n\n// after: exactly the states that can actually exist\ntype Status =\n | { state: 'idle' }\n | { state: 'loading' }\n | { state: 'success'; data: Data }\n | { state: 'error'; message: string };\n```\n\nLook at what the second version buys you beyond tidiness. `loading` and `error` can no longer both be true â the type won't let you construct it. And notice `data` only exists in the `success` state and `message` only in `error`. You can't read `data` while loading because there is no `data` while loading. The compiler now enforces the thing you were previously enforcing by hoping. This is the payoff people mean when they say \"make impossible states unrepresentable\" â it's not aesthetics, it's deleting whole categories of bug by construction.\n\n> Every boolean you add to describe status doubles your state space and trusts you to defend the nonsense corners by hand. A state machine shrinks the space to the states that can really happen and makes the rest impossible to write.\n\n## You often don't need a library\n\nHere's the part that gets lost in the XState hype: a state machine is a *concept*, not a dependency. Most of the time a `useReducer` with explicit states is a complete, honest state machine, and it's the right amount of tool:\n\n```ts\ntype State =\n | { state: 'idle' }\n | { state: 'loading' }\n | { state: 'success'; data: Data }\n | { state: 'error'; message: string };\n\ntype Event =\n | { type: 'FETCH' }\n | { type: 'RESOLVE'; data: Data }\n | { type: 'REJECT'; message: string };\n\nfunction reducer(state: State, event: Event): State {\n switch (state.state) {\n case 'idle':\n return event.type === 'FETCH' ? { state: 'loading' }
1 : state;\n case 'loading':\n if (event.type === 'RESOLVE') return { state: 'success', data: event.data };\n if (event.type === 'REJECT') return { state: 'error', message: event.message };\n return state;\n // success / error handle their own transitions...\n default:\n return state;\n }\n}\n```\n\nThe thing I want you to notice is that transitions are keyed on *the current state first*. A `RESOLVE` event while `idle` does nothing, because you can't resolve a fetch you never started. That's a transition guard falling out naturally from the structure â the machine ignores events that don't make sense in the current state, instead of trusting every handler to check. For a data fetch, a form submit, a toggle with a few modes, this hand-rolled machine is all you need, and reaching for a library would be ceremony.\n\n## When XState actually earns its weight\n\nSo when do I pull in a real state-machine library? When the flow gets complex enough that the *machinery around* transitions becomes the hard part â and a reducer starts sprawling. Concretely, when I hit things like these together:\n\n- **Guards** â a transition is only allowed under a condition (\"can't go to `review` unless every step validated\").\n- **Side effects tied to transitions** â entering `submitting` should fire a request; leaving `editing` should autosave. XState models these as actions and services attached to states, instead of effects scattered across components.\n- **Hierarchy and parallel states** â a media player that is `playing` *and* independently `muted` *and* `fullscreen`, or a wizard where each step has its own sub-states. Statecharts express \"these regions are active at once\" without the boolean explosion coming back through the side door.\n- **A flow worth visualizing** â XState machines can be rendered as an actual diagram, which for a gnarly [multi-step form](/blog/react-playbook-form-libraries) or checkout becomes a shared source of truth that a PM or designer can read.\n\n```ts\nimport { createMachine } from 'xstate';\n\nconst checkoutMachine = createMachine({\n id: 'checkout',\n initial: 'cart',\n states: {\n cart: { on: { NEXT: 'shipping' } },\n shipping: { on: { NEXT: { target: 'payment', guard: 'shippingValid' }, BACK: 'cart' } },\n payment: { on: { NEXT: { target: 'review', guard: 'paymentValid' }, BACK: 'shipping' } },\n review: { on: { SUBMIT: 'placing', BACK: 'payment' } },\n placing: { invoke: { src: 'placeOrder', onDone: 'done', onError: 'review' } },\n done: { type: 'final' },\n },\n});\n```\n\nRead that and you can see the *entire* flow â every state, every legal move, every guard, where the order actually gets placed â in one place. The kind of flow this shines on is exactly the sort of thing I've built before: a policy builder with conditional, dependent steps where \"which screen is even valid right now\" is genuinely hard. When the answer to \"what can happen next\" stops being obvious, a statechart stops being overhead and starts being the only readable description of the feature.\n\n## The cost, said plainly\n\nXState has a learning curve and a vocabulary â actors, guards, actions, services, context â and if you drop it onto a problem that a four-state reducer would've solved, everyone on the team pays that tax for nothing. Do not put a toggle in a statechart. Do not model `idle â loading â success â error` with the full actor system if a reducer says it in fifteen lines. The tool is proportional: booleans for one or two independent flags, a `useReducer` machine for a real-but-contained flow, and XState when hierarchy, guards, side effects, and visualization all show up at once. Matching the weight of the tool to the weight of the problem is most of the skill.\n\n## Closing the cluster\n\nThat's the end of this run on state and data at scale, and there's a single thread through all five pieces. We started with [where state even lives](/blog/react-playbook-local-storage-and-state) â memory versus storage, and why confusing them breaks things. Then [one coherent copy of server data](/blog/react-playbook-data-normalization) instead of five drifting duplicates. Then [optimistic updates](/blog/react-playbook-optimistic-ui) â showing intent early and reconciling with truth. Then [undo/redo](/blog/react-playbook-undo-redo) â changes as explicit, reversible transitions. And now state machines â the states themselves made explicit and finite. Every step was the same instinct: take something ambient and implicit, and make it a thing you can see, name, and reason about.\n\nWhich is exactly the instinct the next cluster needs, because it moves from state *inside* a part of the app to communication *between* the parts. Once you have well-modeled features, the new failure mode is how they talk to each other â and the moment you reach for a global event bus so anything can shout at anything, you can bring back all the implicitness you just spent five articles removing. When does an event bus actually help, and when is it just spaghetti with better branding? That's where we go next.\n\nIf you've got a component right now with three or more status booleans in it, try the exercise: write down the states it's actually allowed to be in. If that list is shorter than two-to-the-number-of-booleans â and it always is â you've just found the impossible states your code is currently paying to survive. Tell me how many you found; the gap is usually bigger than people expect.\n"},{id:"react-playbook-undo-redo",title:"Undo/Redo Without Losing Your Mind: The Command Pattern in React",excerpt:"The instinct is to snapshot your whole state on every change and step backward through the copies. It works until it doesn't. A history of reversible commands scales further, describes what actually changed, and survives contact with a server. Here's how I build undo/redo that doesn't fall apart.",tags:["React","Architecture","React Playbook"],date:"Jul 19, 2026",readTime:"12 min read",coverImage:"/blog/react-playbook-undo-redo/cover.png",published:!0,content:"\nThe [optimistic UI article](/blog/react-playbook-optimistic-ui) ended on a small observation that turns out to be a big one. The rollback in `onError` was a single-step reversal: snapshot the truth, and if things go wrong, restore it. That's undo for exactly one action. Now a user opens your editor, makes twelve changes, and wants to walk back the last five â then redo two of them. Suddenly one snapshot isn't a feature, it's a rounding error. You need a *history*.\n\nUndo/redo looks like a checkbox on a feature list and turns into one of those problems that quietly reshapes your state architecture. Get the model right early and it's almost pleasant. Get it wrong and you'll be rewriting it under deadline pressure the week before launch, because \"add undo\" arrived late and your state wasn't built for reversal.\n\n## The snapshot trap\n\nThe first idea everyone has â myself included, years ago â is to snapshot the entire state on every change and keep the snapshots in an array:\n\n```ts\n// the tempting version\nconst history: EditorState[] = [];\nlet pointer = -1;\n\nfunction commit(next: EditorState) {\n history.splice(pointer + 1); // drop any redo future\n history.push(next);\n pointer = history.length - 1;\n}\nfunction undo() { if (pointer > 0) pointer--; }\nfunction redo() { if (pointer < history.length - 1) pointer++; }\n```\n\nFor a tiny bit of local state, this is genuinely fine â I'll come back to when it's the right answer. But scale it to a real document editor and the cracks show fast. Every keystroke clones the whole state, so memory balloons. You can't tell the user *what* an undo will do, because you never recorded the change â only the before-and-after blobs. And the moment any of that state came from a server, \"restore the old snapshot\" means shipping stale data back over changes other people may have made. The snapshot approach knows *that* something changed but never *what*, and that missing information is exactly what you need for everything interesting.\n\n## Commands: store the change, not the world\n\nThe pattern that scales is old and boring and correct: represent every change as a *command* â an object that knows how to apply itself and how to invert itself. History becomes a stack of commands, not a stack of snapshots.\n\n```ts\ninterface Command {\n label: string; // human-readable: \"Rename layer\", for a menu or tooltip\n apply(state: EditorState): void;\n invert(state: EditorState): void;\n}\n\n// a concrete command carries just enough to undo itself\nfunction renameLayer(layerId: string, from: string, to: string): Command {\n return {\n label: 'Rename layer',\n apply: (s) => { s.layers[layerId].name = to; },\n invert: (s) => { s.layers[layerId].name = from; },\n };\n}\n```\n\nThe whole game is in what `renameLayer` captures: the id, the old value, the new value. That's the *delta* â the minimum needed to move forward and back. Not the entire editor, just this one reversible fact. A thousand of these cost almost nothing to keep, and each one can tell you what it does, because describing itself is part of the command's job.\n\n## Two stacks and one rule\n\nWith commands in hand, the history engine is small and, honestly, satisfying:\n\n```ts\nclass History {\n private undoStack: Command[] = [];\n private redoStack: Command[] = [];\n\n execute(command: Command, state: EditorState) {\n command.apply(state);\n this.undoStack.push(command);\n this.redoStack = []; // a fresh action erases the redo future\n }\n\n undo(state: EditorState) {\n const command = this.undoStack.pop();\n if (!command) return;\n command.invert(state);\n this.redoStack.push(command);\n }\n\n redo(state: EditorState) {\n const command = this.redoStack.pop();\n if (!command) return;\n command.apply(state);\n this.undoStack.push(command);\n }\n}\n```\n\nTwo stacks. Undo pops from one and pushes to the other; redo does the reverse. The single rule worth burning in: **a brand-new action clears the redo stack.** Once you undo three steps and then do something new, the future you undid is gone â you branched off it. Every editor works this way, and users expect it even though they've never thought about it.\n\n## Wiring it to the store\n\nIn this series the app state lives in a [Reatom store](/blog/react-playbook-reatom-state-management), so commands operate on the store and the history sits beside it. The important discipline is that *the only way to change undoable state is through a command*. If some code reaches in and mutates the store directly, that change is invisible to history and undo will silently corrupt the state â undoing things that were never recorded.\n\n```ts\nconst history = new History();\n\n// every user-driven mutation goes through here\nexport function dispatch(command: Command) {\n history.execute(command, store.getState());\n}\n\nexport function undo() { history.undo(store.getState()); }\nexport function redo() { history.redo(store.getState()); }\n```\n\nThis is the same boundary lesson from the [module-dependencies article](/blog/react-playbook-module-dependencies): if there's one door in, you can reason about what comes through it. Undo/redo only w
1orks if every change walks through the command door.\n\n## Two things that separate a demo from a real feature\n\n**Coalescing.** If every keystroke is its own command, hitting undo once removes a single character, and the user has to mash it thirty times to undo a sentence. Nobody wants that. You *coalesce* rapid same-kind commands into one: while the user is typing continuously into the same field, merge keystrokes into a single \"type text\" command, and only seal it when they pause or move focus. It's a small bit of bookkeeping and it's the difference between undo feeling native and feeling broken.\n\n**The server tension.** This is the one that trips people up. If a command already synced its change to the backend, undo is *not* time travel â you can't un-send a request that already happened. Undo becomes a new *forward* action that compensates: to undo a rename that persisted, you send a new rename back to the old value. Model it as \"apply the inverse as a fresh change,\" not \"reach into the past.\" Trying to literally rewind server state is how you get bugs that only appear when two people edit at once. Local, unsynced state can rewind freely; synced state can only be corrected forward.\n\n## When the snapshot approach is actually right\n\nI promised I'd come back to it, because dogma is expensive. If your undoable state is small, fully local, and cheap to copy, snapshotting is the *right* call â commands would be over-engineering. And there's a lovely middle path: if that state is built from [immutable data structures](/blog/react-playbook-immutable-data-structures), a \"snapshot\" is just a reference, since unchanged branches are shared structurally. Keeping a list of immutable roots costs a pointer each, not a deep clone. So the honest decision rule is: small, local, immutable state â snapshots are simplest and fine. Large, partially-server-backed, or you-need-to-describe-the-change â commands. Reach for the heavier pattern when the cheap one actually hurts, not before.\n\n## The thread running underneath\n\nStep back and undo/redo is really about something bigger than a toolbar button. It forces you to model your changes as *discrete, named, reversible transitions* instead of a soup of mutations. The command stack is only possible because you stopped thinking \"state got mutated somehow\" and started thinking \"a specific, describable thing happened, and here's its inverse.\" That shift â from ambient mutation to explicit transitions â is quietly one of the most stabilizing things you can do to a complex client.\n\nWhich points straight at the last piece of this run. Undo/redo made your *changes* explicit. But in a genuinely complex flow â a multi-step wizard, an editor with modes, a checkout â the *states themselves* and the *transitions between them* get complicated enough that a pile of booleans can't describe them without letting impossible combinations sneak in. When modeling the transitions becomes the hard part, you want a tool built for exactly that: a state machine. That's where this cluster ends.\n\nIf you're about to add undo to something, tell me one thing first: is the state you need to reverse local, or has it already touched a server? That single answer decides whether you're building a rewind button or a compensating-action engine, and mixing them up is the most common way this feature goes sideways.\n"},{id:"react-playbook-optimistic-ui",title:"Optimistic UI: Updating Before the Server Says Yes",excerpt:"Optimistic UI is a small lie: you show the result before the server confirms it. Done right, the app feels instant. Done wrong, the UI quietly tells the user something that never happened. The difference is entirely in how you roll back.",tags:["React","Architecture","React Playbook"],date:"Jul 17, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-optimistic-ui/cover.png",published:!0,content:"\nAt the end of the [last article](/blog/react-playbook-data-normalization) I said that once you hold one coherent copy of server state and can invalidate it precisely, a door opens that used to be terrifying: updating the UI *before* the server confirms, and rolling back cleanly if it rejects. That's optimistic UI, and I want to be honest about what it actually is before we make it feel magic.\n\nOptimistic UI is a lie. A small, well-intentioned, usually-correct lie â but a lie. You click the star, and the star fills in *immediately*, before the server has any idea you did anything. You're betting the request will succeed and showing the user the future you expect. Most of the time you're right and the app feels instant. The entire craft is in what happens the rare time you're wrong.\n\n## Why we bother lying at all\n\nThe alternative is honest and awful. User clicks, you show a spinner, you wait for the round trip, then you update. On a good connection that's 200-400ms of the UI sitting there doing nothing after a click that should have felt instant. On a train, it's two seconds of a spinning star. For a like button, a toggle, a reorder â high-frequency, low-stakes actions â that latency is the whole experience, and it makes a fast app feel slow.\n\nSo we front-run the server. The user's intent is almost always going to succeed, so we render success now and reconcile with reality when it arrives. The feeling is immediate; the correctness catches up a beat later.\n\n> Optimistic UI is a promise to the user: \"I'll show you this now, and I guarantee I'll fix it if I was wrong.\" If you can't keep the second half, don't make the first.\n\n## The dangerous naive version\n\nHere's the version I see most often, and it's the one that ships bugs:\n\n```tsx\n// DON'T: optimism with no way back\nfunction useToggleStar(id: number) {\n const [starred, setStarred] = useState(false);\n const toggle = async () => {\n setStarred((s) => !s); // update immediately\n await api.setStar(id, !starred); // hope it works\n };\n return { starred, toggle };\n}\n```\n\nRead what happens when `api.setStar` throws. The state already flipped. There's no snapshot, no catch, no rollback. The star stays filled, the server never recorded it, and the user walks away believing they starred something they didn't. This isn't a cosmetic bug â the UI is now actively lying with no plan to stop. Optimism without rollback isn't optimistic UI. It's just a race you're pretending you won.\n\n## The real shape: snapshot, apply, reconcile\n\nA correct optimistic update always has the same four beats. Cancel anything in flight that could clobber you. Snapshot the current truth. Apply the optimistic change. Then, when the request settles, either keep it (success) or restore the snapshot (failure). Because this series uses [TanStack Query](/blog/react-playbook-data-fetching), the cache is where the truth lives, and the mutation lifecycle gives you exactly these hooks:\n\n```tsx\nconst toggleStar = useMutation({\n mutationFn: (id: number) => api.toggleStar(id),\n\n onMutate: async (id) => {\n // 1. stop in-flight refetches so they can't overwrite our optimistic value\
1n await queryClient.cancelQueries({ queryKey: ['todo', id] });\n\n // 2. snapshot the current truth so we can roll back\n const previous = queryClient.getQueryData<Todo>(['todo', id]);\n\n // 3. apply the optimistic change to the cache\n queryClient.setQueryData<Todo>(['todo', id], (old) =>\n old ? { ...old, starred: !old.starred } : old,\n );\n\n // hand the snapshot to onError via context\n return { previous };\n },\n\n onError: (_err, id, context) => {\n // 4a. reality rejected us â restore exactly what we had\n if (context?.previous) {\n queryClient.setQueryData(['todo', id], context.previous);\n }\n },\n\n onSettled: (_data, _err, id) => {\n // 4b. reconcile with the server's version, success or failure\n queryClient.invalidateQueries({ queryKey: ['todo', id] });\n },\n});\n```\n\nEvery line earns its place. `cancelQueries` is the one people skip and then can't explain the flicker: if a background refetch is already in flight, it will land *after* your optimistic write and stomp it with stale data. Snapshotting into `context` is what makes rollback surgical â you restore the exact previous object, not a guess. And `onSettled` invalidating is the humility step: whatever we optimistically showed, the server's answer is the real one, so we refetch and let truth win. This is why the [previous article's](/blog/react-playbook-data-normalization) precise invalidation mattered â optimistic UI is built directly on top of it.\n\n## Reconciliation is not \"did it work\" â it's \"what's true now\"\n\nThe subtle part is the reconcile step, because the world is not just success-or-failure. Sometimes the request succeeds but the server's result differs from what you optimistically drew. You optimistically added an item at the top of a list; the server inserts it sorted, so it's actually third. You optimistically set a counter to 5; someone else's change already made it 7.\n\nThe rule that keeps you sane: **the server is the source of truth, and reconciliation means adopting its version, not defending yours.** Your optimistic value was a placeholder to make the UI feel alive, nothing more. When the real data arrives, you replace the placeholder â even if it means the item you just \"added to the top\" visibly jumps to its sorted position. That tiny correction is honest and fine. Fighting to keep your optimistic guess because it looks smoother is how you end up with a client that slowly drifts out of sync with the backend.\n\n## Where it belongs, and where the lie gets expensive\n\nI reach for optimistic UI when the action is cheap, reversible, and frequent, and when being wrong is cheap to correct. Toggling a star, liking a post, checking a checkbox, reordering a list, adding a comment. If the request fails, the rollback is a small, understandable flicker, and the user retries. The upside â instant feel â is huge and constant; the downside is rare and mild.\n\nI do *not* reach for it when a wrong guess is expensive or confusing. Payments are the obvious one: never optimistically show \"Payment successful.\" Showing success for a charge that then fails is worse than any spinner. Same for anything irreversible, anything with legal or money weight, and anything where the rollback would be more jarring than the wait â imagine optimistically navigating someone into a screen that then vanishes because the action failed. In those cases the honest spinner is the right call. The question isn't \"can I make this optimistic?\" It's \"if I'm wrong, is the correction cheap and clear?\" When the answer is no, don't tell the lie.\n\n## The reframe\n\nWhat optimistic UI really taught me is that a good client models *intent* and *confirmed truth* as two different things, and knows how to move between them. The optimistic value is intent rendered early. The server response is confirmed truth. Reconciliation is the bridge. Once you hold those as separate ideas, the whole pattern stops feeling like a hack and starts feeling like what it is: a disciplined way to decouple how fast the UI feels from how fast the network actually is.\n\nAnd notice what the rollback in `onError` really was â a single-step reversal back to a snapshot you took a moment ago. That works beautifully for one action. But the moment users want to reverse a *sequence* of actions â undo the last five edits in an editor, then redo two of them â a lone snapshot isn't enough. You need a history of reversible changes, which is a different and genuinely interesting problem. That's undo/redo, and it's next.\n\nIf you've got an optimistic update in y
1our app right now, do me a favor and check one thing: what happens on `onError`? If you can't point to the line that restores the previous value, you don't have optimistic UI â you have a lie with no exit. Tell me what you find.\n"},{id:"react-playbook-data-normalization",title:"Server State Is a Cache: Normalization, Duplicates, and Invalidation You Can Trust",excerpt:"The same customer shows up in a list, a detail page, and a header badge â three copies of one truth you don't own. Edit one and the others go stale. Here's when to normalize server data into one source of truth, when the query cache is enough, and how to invalidate without nuking everything.",tags:["React","Architecture","React Playbook"],date:"Jul 15, 2026",readTime:"12 min read",coverImage:"/blog/react-playbook-data-normalization/cover.png",published:!0,content:"\nThe [previous article](/blog/react-playbook-local-storage-and-state) drew a hard line: state the client owns versus data it doesn't. Everything there was about the stuff you own â theme, drafts, preferences â where you are the source of truth and storage just remembers it. This one is about the other side of that line, and it's where a lot of otherwise-clean apps quietly turn to mush.\n\nHere's the situation. You fetch a list of customers for a table. You fetch one customer for a detail page. There's a little avatar-and-name badge in the header showing the currently selected customer. That's three network responses. And customer `#42` appears in all three. You now have three copies of one entity in memory, and â this is the part that bites â nothing keeps them in agreement. Edit the customer's name on the detail page, and the table still shows the old name until something refetches it. The badge shows a third version. Your UI is now lying to the user, and the bug report says \"the name didn't update,\" which tells you nothing about why.\n\nThis is the server-state problem, and it is fundamentally different from the local-state problem. You don't own this data. It has an owner on the backend. Every copy you hold is a *cache* of a truth that lives somewhere else and can change without telling you.\n\n## The duplication trap\n\nThe instinct is to treat each fetch as its own little pile of data. List query returns an array, you render it. Detail query returns an object, you render it. Header does its own thing. Each is correct in isolation. The problem is that the same entity is now stored in multiple unrelated places, and a write to one has no path to the others.\n\n> The bug isn't that data goes stale. It's that you have more than one copy of the same truth, and no single place responsible for it.\n\nThere are two honest ways out, and picking the wrong one is where teams either over-engineer or under-engineer for years.\n\n## Option one: normalize into one source of truth\n\nNormalization is the database idea, moved into the client. Instead of storing whatever shape each response happened to have, you break responses into *entities keyed by id*, store each entity exactly once, and have every view reference it by id.\n\n```ts\n// denormalized â the same customer duplicated across responses\nconst listResponse = [{ id: 42, name: 'Acme Corp', tier: 'gold' }, /* ... */];\nconst detailResponse = { id: 42, name: 'Acme Corp', tier: 'gold', notes: '...' };\n\n// normalized â one customer, referenced by id\nconst cache = {\n customers: {\n 42: { id: 42, name: 'Acme Corp', tier: 'gold', notes: '...' },\n },\n // the list is now just an ordered array of ids into the store above\n customerList: [42, 17, 8],\n};\n```\n\nNow there is exactly one `customers[42]`. The table reads `customerList` and looks each id up. The detail page reads `customers[42]`. The header badge reads `customers[42]`. Update that one record and every view re-reads the same object â they can't disagree, because there's nothing to disagree with. This is what normalized caches like RTK Query, Apollo, and the `normalizr`-style approach give you: a mini in-memory database where entities live once and views are just queries into it.\n\nThe cost is real, though, and it's why I don't reach for it by default. You need an i
1dentity for everything (what's the id? what about entities without one?), you need to teach the cache how to normalize each response shape, and nested relationships get fiddly. You've essentially adopted a client-side ORM. For a CRM-scale app with heavy entity reuse and live updates, that's a good trade. For most apps, it's a lot of machinery for a problem you can solve more cheaply.\n\n## Option two: let the query cache own it, invalidate precisely\n\nThe cheaper answer â and the one this series defaults to, because it uses [TanStack Query](/blog/react-playbook-data-fetching) â is a *document* cache rather than a normalized one. Data is cached per query key, not per entity. `['customers']` is one cache entry; `['customer', 42]` is another. There genuinely are two copies of customer `#42`. The trick is that you don't try to keep them manually in sync â you make the cache re-fetch the affected keys after a change, and you do it *surgically*.\n\nThe blunt instrument everyone reaches for first:\n\n```ts\n// after editing a customer â refetch literally everything customer-related\nawait queryClient.invalidateQueries();\n```\n\nThis \"works\" and it's a performance and UX disaster. Every table, every dropdown, every cached query on the screen refetches, spinners bloom everywhere, and you've turned one edit into a network storm. Invalidation is not a hammer. It's a scalpel:\n\n```ts\nconst mutation = useMutation({\n mutationFn: updateCustomer,\n onSuccess: (updated) => {\n // 1. patch the detail cache directly â no refetch needed, we have the data\n queryClient.setQueryData(['customer', updated.id], updated);\n\n // 2. mark just the list stale, so it refetches when next observed\n queryClient.invalidateQueries({ queryKey: ['customers'], exact: true });\n },\n});\n```\n\nTwo precise moves. `setQueryData` writes the fresh entity straight into the detail cache, so the detail page updates instantly with zero network. `invalidateQueries` with a specific key marks only the list as stale â and critically, TanStack Query won't even refetch it until something is actually looking at it. Nothing else on the page moves. That precision is the whole skill. The library gives you a scalpel; most bugs come from swinging it like a hammer.\n\n## The mental shift: query keys are your schema\n\nHere's the reframe that made this click for me. In a normalized cache, your entity ids *are* the schema â everything hangs off `customers[42]`. In a document cache, your **query keys** play that role. The structure and discipline of your query keys is what makes precise invalidation possible or impossible.\n\nIf your keys are ad hoc â `['customers']` here, `['customerList', filters]` there, `['allCustomers']` in a third place â you can't invalidate precisely because you can't *describe* the set of things that went stale. But if keys are hierarchical and consistent, invalidation becomes expressive:\n\n```ts\n// a consistent, hierarchical key factory\nconst customerKeys = {\n all: ['customers'] as const,\n lists: () => [...customerKeys.all, 'list'] as const,\n list: (filters: Filters) => [...customerKeys.lists(), filters] as const,\n details: () => [...customerKeys.all, 'detail'] as const,\n detail: (id: number) => [...customerKeys.details(), id] as const,\n};\n\n// invalidate every customer list, regardless of filters, and nothing else\nqueryClient.invalidateQueries({ queryKey: customerKeys.lists() });\n```\n\nBecause TanStack Query matches keys by prefix, `customerKeys.lists()` invalidates every filtered list variant in one call without touching a single detail cache. The key factory turns \"which things are now stale\" into a sentence you can write. This is the same instinct as a good [public API or module boundary](/blog/react-playbook-module-dependencies): a little structure up front makes the operations you'll do a thousand times both possible and safe.\n\n## How to actually choose\n\nI'll be direct, because this is where people waste the most effort. Reach for a **normalized cache** when three things are true together: the same entities are reused heavily across many views, they update frequently (real-time, collaborative, or lots of mutations), and staleness between views is genuinely user-visible and unacceptable. Trading dashboards, chat apps, collaborative editors, big CRMs â normalization earns its keep there.\n\nFor everything else â which is most apps â a **document cache with disciplined query keys and precise invalidation** is not a compromise, it's the right call. It has less machinery, less to learn, fewer ways to corrupt the cache, and it scales further than people expect. Honestly, the number of teams who adopted a full normalized cache and then used it like a document cache anyway is not small. Start with the simpler model and let real pain â not anticipated pain â push you to the heavier one.\n\nEither way, the principle underneath both is identical, and it's the thing to actually remember: **there is one truth, it lives on the server, and your job is to hold one coherent copy of it â not five copies you hope agree.** Normalization enforces that with entity ids. A query cache enforces it with keys and invalidation. Pick your mechanism, but don't skip the principle, because \"the name didn't update\" is what skipping it looks like in the bug tracker.\n\nOnce you've got one coherent copy and precise invalidation, a door opens that used to be terrifying: updating the UI *before* the server confirms, and rolling back cleanly if it rejects. That's optimistic UI, and it's only sane once your cache has a single source of truth to optimistically edit and a precise way to reconcile when the truth comes back. That's next.\n\nIf your app has a \"why is this showing the old value\" bug right now, I'd bet it's a duplication problem wearing a staleness costume. Tell me where the copies live â list and detail, cache and store, two tabs â and I'll tell you which of these two models you actually needed.\n"},{id:"react-playbook-local-storage-and-state",title:"localStorage Is Not State: Persistence, Hydration, and Tabs That Fight Each Other",excerpt:"localStorage looks like a global variable that survives a refresh. That framing is the bug. Treat it as a persistence layer that mirrors your state â not as the state itself â and the hydration mismatches, stale tabs, and schema breakages mostly disappear.",tags:["React","Architecture","React Playbook"],date:"Jul 13, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-local-storage-and-state/cover.png",published:!0,content:"\nAt the end of the [last article](/blog/react-playbook-ui-kit-versioning) I said I wanted to start this run with the deceptively simple persistence question: what belongs in localStorage, what belongs in state, and what breaks when you pretend they're the same thing. It sounds like a beginner question. It isn't. I've seen senior codebase
1s get this wrong in ways that produce flickering UIs, tabs that overwrite each other, and a crash the first time someone ships a new version of the app.\n\nThe root mistake is almost always the same, and it's a framing problem. `localStorage` looks like a global variable that happens to survive a refresh. `localStorage.getItem('theme')` reads like `window.theme`. So people start treating it as a place to *keep* state â reading from it in render, writing to it inline, scattering `getItem`/`setItem` across components. That framing is the bug. Everything downstream goes wrong because of it.\n\n## Three concerns people mash into one\n\nWhen someone says \"I'll just store it in localStorage,\" they're usually blending three separate things that have nothing to do with each other:\n\n**Source of truth** â where the current value lives *right now*, in memory, reactive, driving the UI. That's your state (a store atom, context, `useState`).\n\n**Persistence** â how that value survives a reload. That's storage: localStorage, IndexedDB, a cookie.\n\n**Cross-tab sync** â how two open tabs agree on the value. That's a messaging problem.\n\nlocalStorage can only honestly do the middle one. It is not reactive â writing to it doesn't re-render anything. It is synchronous and string-only. And it's shared across tabs in a way that will surprise you. The moment you ask it to be your source of truth, you've handed the hardest job to the tool least suited for it.\n\n> Storage is a mirror of your state, not the state itself. The state lives in memory and drives the UI; storage just remembers what it was so you can restore it later.\n\nOnce that sentence is the rule, the architecture falls out of it. State is the thing components read and write. Storage is a side effect: you hydrate *from* it once at startup, and you persist *to* it when state changes. Components never touch storage directly.\n\n## The render-time read that breaks SSR\n\nHere's the most common concrete failure, and it's worth seeing clearly. Someone initializes state straight from storage:\n\n```tsx\n// looks fine, quietly broken\nfunction useTheme() {\n const [theme, setTheme] = useState(localStorage.getItem('theme') ?? 'light');\n // ...\n}\n```\n\nOn a purely client-rendered app this mostly works. On anything server-rendered â Next.js, which is what this whole series builds on â it detonates two ways. First, `localStorage` doesn't exist on the server, so the render throws. Second, even if you guard it, the server has no idea what's in the user's storage, so it renders `light`, the client hydrates with `dark`, and React screams about a hydration mismatch â or worse, silently flips the UI after paint. That flash of the wrong theme is the visual signature of \"someone read storage during render.\"\n\nThe fix follows directly from the mirror rule. Render from state with a stable default, then hydrate from storage in an effect, after the first paint:\n\n```tsx\nfunction useTheme() {\n const [theme, setThemeState] = useState<'light' | 'dark'>('light');\n\n // hydrate once, on the client only\n useEffect(() => {\n const stored = localStorage.getItem('theme');\n if (stored === 'light' || stored === 'dark') setThemeState(stored);\n }, []);\n\n const setTheme = useCallback((next: 'light' | 'dark') => {\n setThemeState(next);\n localStorage.setItem('theme', next); // persist as a side effect\n }, []);\n\n return [theme, setTheme] as const;\n}\n```\n\nServer and client both start at `light`, so hydration matches. The stored value takes over a tick later. If the theme flash bothers you, that's a real concern â but you solve it with a tiny inline script in the document head that sets a class before React boots, not by reading storage during React's render. The two problems stay separate.\n\n## Persist the store, not a hundred components\n\nDoing this hook-by-hook gets old fast, and it scatters `setItem` calls everywhere â which is exactly the kind of smeared side effect that makes a codebase hard to reason about. Since this series uses [Reatom for state](/blog/react-playbook-reatom-state-management), persistence belongs at the store layer, as one declarative concern, not a hundred manual writes.\n\n```ts\nimport { atom } from '@reatom/core';\nimport { withLocalStorage } from '@reatom/persist-web-storage';\n\n// the atom is the source of truth; localStorage is a declared mirror of it\nexport const themeAtom = atom<'light' | 'dark'>('light', 'themeAtom').pipe(\n withLocalStorage('theme'),\n);\n```\n\nNow the atom is where the value lives and where the UI reads it. The persistence adapter mirrors every change to localStorage and rehydrates on load â including, importantly, listening for changes from *other tabs*. You wrote zero `getItem`/`setItem` calls. The concern lives in one place you can see, test, and change. This is the same principle from the [composition and DI article](/blog/react-playbook-composition-and-di): don't let components reach out and grab infrastru
1cture; hand it to them, or push it to a layer that owns it.\n\n## Two tabs, one storage, a fight\n\nThe surprise that catches people: localStorage is shared across every tab on the same origin. Open your app in two tabs, change the theme in one, and the other tab's *in-memory state* has no idea. Its localStorage updated underneath it, but React state didn't move. The two tabs now disagree, and whichever one writes next wins, clobbering the other.\n\nThe browser gives you exactly one tool for this â the `storage` event, which fires in *other* tabs (never the one that made the change):\n\n```ts\nuseEffect(() => {\n const onStorage = (e: StorageEvent) => {\n if (e.key === 'theme' && (e.newValue === 'light' || e.newValue === 'dark')) {\n setThemeState(e.newValue);\n }\n };\n window.addEventListener('storage', onStorage);\n return () => window.removeEventListener('storage', onStorage);\n}, []);\n```\n\nA good persistence adapter (like Reatom's above) already does this for you, which is another reason to centralize it. But you should know the event exists, because the \"tabs fight each other\" bug is invisible in development â you almost never have two tabs open while coding â and then very visible in production. If tab sync actually matters for your feature, `BroadcastChannel` is the more deliberate tool; `storage` is the free one you get for persisting at all.\n\n## Versioning: the crash that ships with v2\n\nHere's the failure that only shows up *after* you succeed. You persist a settings object. Months later you change its shape â rename a field, split one setting into two. You deploy. Every returning user's browser hydrates the *old* shape into code that expects the *new* shape, and something reads `settings.notifications.email` on an object where `notifications` is now a string. Crash. On load. For existing users only â which is why your testing missed it.\n\nPersisted data is data you shipped in the past, running against code you shipped today. It needs a version and a migration path, exactly like a database:\n\n```ts\ntype StoredSettings = { version: number; theme: 'light' | 'dark'; density: 'cozy' | 'compact' };\n\nfunction loadSettings(): StoredSettings {\n const raw = localStorage.getItem('settings');\n const parsed = raw ? JSON.parse(raw) : null;\n\n // no data, or an old shape we no longer trust â start clean\n if (!parsed || parsed.version !== 2) {\n return { version: 2, theme: 'light', density: 'cozy' };\n }\n return parsed;\n}\n```\n\nReject-and-reset is the cheapest correct answer for preferences â losing someone's theme choice is not a tragedy. For anything you can't throw away, write real migrations keyed on `version`. Either way, the non-negotiable part is: **never trust the shape of persisted data**. It came from a version of your app you don't control anymore.\n\n## So what actually goes in there\n\nPulling it together, the fit test is simple: store things the client owns and the client alone can restore. Theme and UI preferences. Sidebar collapsed or expanded. A draft of a form the user hasn't submitted. The last route they were on. Small, client-owned, non-sensitive, and fine to lose in the worst case.\n\nWhat doesn't belong: anything sensitive (localStorage is readable by any script on the page â a token there is a token exposed to every XSS), anything large (multi-megabyte blobs want IndexedDB, which is async and built for size), and â the big one â server data. The list of customers, the current user's profile, the order you just fetched: that data is not yours. It has an owner on the backend, and your copy is a *cache*, not a persisted preference. Stuffing it into localStorage means you now have two stale copies fighting instead of one.\n\nAnd that's the seam into the next article. Everything here was about state the client *owns* â you're the source of truth, storage just remembers it. Server state flips that on its head: the source of truth lives somewhere else, the same entity shows up in five different responses, and \"persistence\" stops being the problem while \"which copy is correct\" becomes it. That's data normalization and cache invalidation, and it's where a lot of otherwise-clean apps quietly turn to mush.\n\nIf you've got a persistence bug you can't quite pin down â a flash on load, a tab that resets another, a crash that only hits returning users â tell me the symptom. Nine times out of ten it traces straight back to treating storage as state, and I'd like to hear the tenth.\n"},{id:"react-playbook-ui-kit-versioning",title:"Keeping a UI Kit Updated Across Apps: Versioning, Contracts, and Trust",excerpt:"A shared UI kit does not fail because the button component is hard to write. It fails when five applications are afraid to upgrade it. Versioning, contract tests, release channels, and migration paths are what turn a component library from a dependency into a product people can trust.",tags:["React","Architecture","React Playbook"],date:"Jul 10, 2026",readTime:"12 min read",coverImage:"/blog/react-playbook-ui-kit-versioning/cover.png",published:!0,content:"\nThe hardest part of a UI kit is not writing the components. That's the trap. You can build a clean `<Button>`, a flexible `<Select>`, a beautiful `<Modal>`, publish them to a package, and feel like the system exists. Then six months later one app is on version 1.4, another is on 2.1, a third has copied half the styles locally, and everyone is scared to upgrade be
1cause nobody knows what will break.\n\nAt that point the UI kit did not fail as a component library. It failed as a product.\n\nThe [previous article](/blog/react-playbook-changing-shared-components) was about changing a shared component inside one codebase, where you can grep every call site and run a codemod yourself. This is the harder version: several applications consume the kit as a versioned package. You can't see every usage. You can't update every app in one pull request. You can't assume every team upgrades on the same day. So the question changes from \"how do I make the component better?\" to \"how do I make upgrades safe enough that people keep taking them?\"\n\nThat's the real work.\n\n## A UI kit is not shared until upgrades are routine\n\nA lot of teams treat \"published package\" as the finish line. It isn't. A package can be shared and still be effectively frozen. If every upgrade requires a migration meeting, manual QA across five apps, and someone brave enough to merge first, the kit is not healthy. It's a museum.\n\nThe signal I care about is boring: can an app upgrade from 2.3.1 to 2.3.2 on a normal Tuesday without drama? Can it take a minor version without reading every diff? Can it delay a major version without blocking everyone else? If the answer is yes, the UI kit is alive. If the answer is no, every improvement you make piles up behind the same wall: adoption risk.\n\n> A design system is only as real as its upgrade path. If people cannot safely move to the next version, the new version mostly exists in your repository.\n\nThis is why \"one version everywhere\" is a nice goal and a terrible starting assumption. You may want every app on the latest version. You probably should. But the mechanism that gets you there is not hope. It's compatibility, contracts, release discipline, and migration support.\n\n## Semver is a promise, not a decoration\n\nVersion numbers are not vibes. They are a communication system between the kit and every app that depends on it. If you treat them casually, people stop trusting them.\n\nThe contract is simple:\n\n```json\n{\n \"name\": \"@company/ui-kit\",\n \"version\": \"2.4.0\"\n}\n```\n\nA patch release fixes bugs and should be safe. A minor release adds capabilities without breaking existing usage. A major release may require changes, and the migration path must be explicit. That's semver on paper. In practice, the hard part is being honest about what counts as breaking.\n\nChanging a prop type is obviously breaking. Removing a component is breaking. But so is changing keyboard behavior in a menu. So is changing focus trapping in a modal. So is changing the DOM structure if consumers style nested elements, even if you wish they didn't. So is renaming a CSS variable that apps rely on. UI libraries have more contracts than TypeScript can see.\n\nThat means the rule has to be conservative: if a reasonable consumer could notice the change in code, behavior, accessibility, or visual output, treat it as a contract change. Maybe it's still worth doing. Just don't smuggle it into a patch release and call the upgrade safe.\n\n## Public API must be smaller than the folder tree\n\nThis connects directly back to the [barrel files article](/blog/react-playbook-barrel-files). A UI kit needs a public API, but that API has to be intentional. If apps can import from anywhere inside the package, every internal file becomes a promise you didn't mean to make.\n\nBad shape:\n\n```tsx\nimport { Button } from '@company/ui-kit/src/components/button/button';\nimport { buttonRecipe } from '@company/ui-kit/src/components/button/styles';\nimport { useModalMachine } from '@company/ui-kit/src/internal/modal-machine';\n```\n\nNow your internal structure is part of the external contract. You can't reorganize files. You can't swap the modal implementation. You can't rename a style recipe. The consuming apps have turned your source tree into their API.\n\nGood shape:\n\n```tsx\nimport { Button, Dialog, Select } from '@company/ui-kit';\nimport { useCombobox } from '@company/ui-kit/headless';\nimport { tokens } from '@company/ui-kit/tokens';\n```\n\nThree doors. Components, headless behavior, tokens. Everything else is private. If someone needs a new door, you add it intentionally. You don't let them crawl through a window because the import path happened to work.\n\nIn package terms, that means using `exports` to make the boundary real:\n\n```json\n{\n \"name\": \"@company/ui-kit\",\n \"exports\": {\n \".\": \"./dist/index.js\",\n \"./headless\": \"./dist/headless/index.js\",\n \"./tokens\": \"./dist/tokens/index.js\"\n }\n}\n```\n\nThis is a small detail with a large consequence. You can only version what you control. A tight public API gives you something finite to protect.\n\n## Contract tests are how apps talk back\n\nThe uncomfortable part of maintaining a UI kit across apps
1is that unit tests inside the kit are not enough. They tell you your component works according to your assumptions. They do not tell you whether the apps use it in ways you forgot to protect.\n\nThe answer is not to pull every application into your test suite. That turns the kit into a build-system hostage. The better answer is a thin layer of contract tests: small fixtures that represent the promises the kit makes to consumers.\n\n```tsx\n// contracts/dialog.contract.tsx\nimport { render, screen } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\nimport { Dialog } from '@company/ui-kit';\n\nit('keeps focus inside the dialog until it closes', async () => {\n render(\n <Dialog open title=\"Delete customer\">\n <button>Cancel</button>\n <button>Delete</button>\n </Dialog>,\n );\n\n await userEvent.tab();\n expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus();\n\n await userEvent.tab();\n expect(screen.getByRole('button', { name: 'Delete' })).toHaveFocus();\n});\n```\n\nThat test is not checking implementation. It is checking a consumer-visible promise: focus stays inside the dialog. If you rewrite the internals from one focus-trap library to another, the contract should still pass. If it doesn't, you didn't make a refactor. You changed behavior.\n\nYou need contracts in four layers:\n\n**Type contracts.** A small TypeScript test project that imports the public API the way apps do. If exported types break, the kit fails before publishing.\n\n**Behavior contracts.** Testing Library tests for keyboard behavior, focus, ARIA, controlled/uncontrolled state, and callback order.\n\n**Visual contracts.** Storybook or fixture pages with visual regression on the stable states: default, hover, disabled, loading, error, long text, r
1ight-to-left if you support it.\n\n**Token contracts.** Tests that verify CSS variables and design tokens still exist with expected names. Tokens are API. Treat them that way.\n\nNot every component needs all four layers. A plain `Badge` probably needs visual and token coverage. A `Combobox` needs behavior and accessibility coverage badly. The point is to test the promises consumers depend on, not every internal branch.\n\n## Release channels keep risk from spreading\n\nIf every release goes straight to `latest`, your first consumer becomes the canary whether they agreed to it or not. That's not fair to them, and it trains people to wait. A better release flow gives the kit a place to be wrong before the stable version moves.\n\nFor example:\n\n```bash\n# experimental builds for early adopters\nnpm publish --tag next\n\n# stable builds after contracts and canary apps pass\nnpm publish --tag latest\n```\n\nThe `next` tag is where breaking work, risky rewrites, and big visual changes can be tested by one or two willing apps. `latest` is the calm channel. It should feel boring. If a team installs `latest` and gets surprise churn, they will stop trusting the kit. And when trust goes, adoption goes with it.\n\nThis does not require a huge platform. Start with one canary app â ideally the app that uses the kit heavily and has the fastest feedback loop. Upgrade it first. Run its smoke tests. Look at the core screens. Fix the kit before asking everyone else to move. The canary app is not there to prove the kit is perfect. It is there to catch the obvious problems while the blast radius is still small.\n\n## Migration support is part of the feature\n\nA breaking release without a migration path is not a release. It's homework assigned to every consuming app.\n\nIf you remove `Button color` in favor of `Button intent`, the release should include more than a changelog sentence. It should include the same tools from the previous article, but packaged for consumers who live outside your repo:\n\n```json\n{\n \"scripts\": {\n \"migrate:ui-kit\": \"npx @company/ui-kit-codemods button-color-to-intent src\"\n }\n}\n```\n\nThe codemod can ship as a small companion package. The migration guide should show old usage, new usage, edge cases, and what the script can and cannot fix. The deprecation should exist for at least one minor cycle before the major removal, unless the old behavior is actively dangerous. And the major release should be boring precisely because the hard work already happened while the old API still existed.\n\nHonestly, this is where many UI kits lose people. The maintainers ship a clean new API and assume consumers will be grateful. Consumers look at their backlog, see forty files to touch, and pin the old version for another year. The new API may be better, but the upgrade experience is worse, so nobody moves.\n\n## Documentation has to be versioned too\n\nOne small but painful failure mode: the documentation shows the latest version, but half the apps are still on an older one. A developer opens the docs, copies an example, and the prop doesn't exist. Now the UI kit feels broken even though the app is just behind.\n\nIf multiple apps can be on different versions, the docs need to acknowledge that reality. At minimum, every docs page should clearly say which version it describes. Better: keep versioned docs for major versions, or at least preserve migration pages long after the release goes out. A migration guide is most useful six months after you wrote it, when the last app finally upgrades.\n\nThis also applies to design tokens and screenshots. If your docs render from the latest package but an app is on v2, the visual examples can lie. Sometimes that's acceptable. Sometimes it creates bugs. Be explicit either way.\n\n## The rule I use: optimize for trust\n\nThere are many tactical choices here â monorepo or separate repo, Changesets or semantic-release, Storybook or Ladle, visual regression provider A or B. Those choices matter, but they're not the center. Trust is the center.\n\nTrust means a patch release doesn't redesign the app. Trust means a minor release doesn't remove a prop. Trust means a major release comes with a migration path. Trust means public imports stay public and private imports are actually blocked. Trust means if a component's behavior changes, there is a test that calls it a behavior change. Trust means teams believe the kit maintainers are trying to make upgrades smaller, not throw work over the wall.\n\nOnce that trust exists, you can improve the UI kit continuously. Without it, every improvement becomes suspicious. People fork. They pin. They copy styles locally. They build one-off replacements because waiting for the shared kit feels slower than escaping it. That is how a design system dies: not from one bad component, but from a long series of upgrades people chose not to take.\n\nThis closes the component-patterns run: compound components for structure, headless components for behavior, incremental migration inside one repo, and now versioned upgrades across many apps. Next I want to move into state and data at scale, starting with the deceptively simple persistence question: what belongs in local storage, what belongs in global state, and what breaks when you pretend they're the same thing.\n\nIf your UI kit is stuck on an old version somewhere, I'm curious what the real blocker is. Is it semver trust, missing migration tools, visual regression fear, or just nobody owning the upgrade? Send me the ugly reason. That's usually where the architecture lives.\n"},{id:"react-playbook-changing-shared-components",title:"Changing a Component Everyone Uses: Deprecation, Codemods, and No Big-Bang Rewrites",excerpt:"Writing the new version of a shared component is the easy 10%. The other 90% is the hundred call sites that already depend on the old one â and the fear of touching them. Here's how I change a component everyone uses without a month-long rewrite that breaks the day it lands.",tags:["React","Architecture","React Playbook"],date:"Jul 09, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-changing-shared-components/cover.png",content:"\nAt the end of the [last article](/blog/react-playbook-headless-components) I told you to go find the one component everyone reimplements slightly differently. So let's say you found it. You know exactly how it should work now. There's just one problem: a hundred files already import the old one, and half of them belong to teams you've never met.\n\nThis is the part of building shared components nobody puts on the roadmap. Writing the new `<Button>` is the easy 10%. The other 90% is every place that already calls the old `<Button>`, and the very specific fear that comes with editing code you don't own to fix a component that, technically, still works. That fear is why so many \"we'll clean this up later\" components never get cleaned up. The change isn't hard. Changing it *everywhere, safely, without a flag day* is hard.\n\nSo this article isn't about how to design a better component. It's about how to move an existing one forward when it already has dozens of dependents â deprecation paths, codemods, and the discipline that lets you touch a hundred call sites without holding your breath.\n\n## The big-bang rewrite is the trap\n\nHere's the instinct I have to talk teams out of constantly. You look at the old component, you see everything wrong with it, and you decide to rewrite it properly on a branch. Two weeks later that branch has touched sixty files, it's three hundred commits behind main, every rebase is a knife fight, and the only way to land it is a single terrifying merge that changes everything at once. If anything breaks â and something always breaks â you can't tell which of the sixty changes did it.\n\nThat's a flag day: a moment where the whole codebase has to flip from old to new simultaneously. Flag days feel decisive and they are almost always a mistake. The problem isn't your ability to write the new component. The problem is that you tried to *break* and *rebuild* in the same step, so there's never a moment where the system is in a known-good state. You're either fully migrated or fully broken, with nothing in between.\n\nThe fix is to refuse the whole framing. You don't rewrite. You *evolve*.\n\n> You never break and rebuild in one step. You add the new path, move callers over one at a time, then delete the old path. Three boring, reversible steps instead of one heroic irreversible one.\n\nThat shape has a few names â parallel change, expand/contract, the strangler pattern â but at the component level it's just this: **expand** the component so old and new both work, **migrate** the call sites gradually, then **contract** by removing the old way once nothing uses it. Every intermediate state ships. Every step is revertible on its own. Nothing has a flag day.\n\n## Step one: make the change additive\n\nThe whole strategy depen
1ds on old and new coexisting for a while. So the first move is to add the new API *next to* the old one instead of replacing it. Say I'm moving a `<Button>` from a raw `color` prop to a semantic `intent` prop â because `color=\"green\"` tells you nothing about *why* it's green, and the theme can't remap it.\n\nThe tempting move is to rename `color` to `intent` and let TypeScript light up a hundred errors. Don't. Add `intent`, keep `color`, and translate internally:\n\n```tsx\ntype ButtonProps = {\n /** @deprecated Use `intent` instead. `color` is removed in v3. */\n color?: 'green' | 'red' | 'gray';\n intent?: 'success' | 'danger' | 'neutral';\n children: React.ReactNode;\n};\n\nconst colorToIntent = {\n green: 'success',\n red: 'danger',\n gray: 'neutral',\n} as const;\n\nexport function Button({ color, intent, children }: ButtonProps) {\n // new prop wins; old prop still works via a mapping\n const resolved = intent ?? (color ? colorToIntent[color] : 'neutral');\n\n if (process.env.NODE_ENV !== 'production' && color && !intent) {\n console.warn(\n `Button: the \\`color\\` prop is deprecated. Use intent=\"${colorToIntent[color]}\" instead.`,\n );\n }\n\n return <button className={intentClasses[resolved]}>{children}</button>;\n}\n```\n\nNobody's build breaks. Every existing `color=\"green\"` keeps rendering exactly as before. New code uses `intent`. And critically, the component itself now *knows* the mapping from old to new â which is going to matter in about thirty seconds when we automate the migration.\n\n> A backward-compatible change buys you time, and time is the one thing a big-bang rewrite refuses to give you. Coexistence isn't a compromise. It's the whole plan.\n\n## Step two: deprecation is a message, not a delete\n\nMarking something `@deprecated` is not the same as deleting it, and treating them as the same thing is why deprecations get ignored. A deprecation is a *communication channel*. Its entire job is to tell the next person who touches this code what to do instead â clearly, at the exact moment they'd otherwise reach for the old thing.\n\nThat JSDoc `@deprecated` tag isn't decorative. Editors strike through the prop and surface the message on hover, so a developer typing `color=` sees the replacement before they even hit save. The runtime warning catches the code that's already written. And if you want teeth, an ESLint rule turns \"please stop\" into \"the build is yellow until you stop\":\n\n```js\n// eslint config â nudge, don't block yet\n'no-restricted-syntax': [\n 'warn',\n {\n selector: 'JSXAttribute[name.name=\"color\"]',\n message: 'Button: the color prop is deprecated â use intent. Run the codemod: npx tsx scripts/button-color-to-intent.ts',\n },\n],\n```\n\nThe one rule I hold to: **a deprecation must always name its replacement.** \"This is deprecated\" with no path forward isn't a deprecation, it's nagging. Every warning I write ends with the exact thing to type instead, and ideally the command that'll do it for you. Which brings us to that command.\n\n## Step three: codemods move the boring changes for you\n\nHere's where the hundred call sites stop being scary. If the change is mechanical â `color=\"green\"` becomes `intent=\"success\"`, every time, no judgment required â then asking humans to do it a hundred times is both slow and a source of typos. A codemod is a script that rewrites source code by editing its syntax tree, and this is exactly what they're for.\n\n```ts\n// scripts/button-color-to-intent.ts â jscodeshift transform\nimport type { API, FileInfo } from 'jscodeshift';\n\nconst map: Record<string, string> = { green: 'success', red: 'danger', gray: 'neutral' };\n\nexport default function transform(file: FileInfo, api: API) {\n const j = api.jscodeshift;\n const root = j(file.source);\n\n root\n .find(j.JSXAttribute, { name: { name: 'color' } })\n // only touch color props on <Button>\n .filter((path) => {\n const el = path.parent.node;\n return el.name?.type === 'JSXIdentifier' && el.name.name === 'Button';\n })\n .forEach((path) =>
1 {\n const value = path.node.value;\n if (value?.type === 'Literal' && typeof value.value === 'string' && map[value.value]) {\n path.node.name.name = 'intent';\n value.value = map[value.value];\n }\n });\n\n return root.toSource();\n}\n```\n\nRun it across the repo and every `<Button color=\"green\">` becomes `<Button intent=\"success\">` in one commit â a commit a reviewer can actually read, because the diff is uniform and mechanical. That's the real payoff: not just speed, but a *reviewable* migration instead of a hundred hand-edited files where any one of them could hide a mistake.\n\nCodemods aren't always the answer, though, and I've watched people burn a day writing one for a change they could've done by hand in ten minutes. My test is two questions. Are there enough call sites that doing it manually is genuinely painful â dozens, not five? And is the transform *mechanical*, with no case where a human has to decide what the right replacement is? If either answer is no, skip the codemod. When the change needs judgment â \"replace this prop, but only where the old behavior was actually intended\" â a script can't make that call, and pretending it can just moves the bugs somewhere quieter.\n\n## When it can't be additive: version it side by side\n\nSometimes the new component isn't a tweaked version of the old one â it's a different thing that happens to share a name. A `<DataTable>` that moves from a props-driven column config to a headless row model isn't a new prop; it's a new mental model. You can't map the old API onto the new one, so coexistence-through-props doesn't work.\n\nThat's when you version at the module level instead of the prop level. Ship the new one alongside the old under its own name, and let call sites move when they're ready:\n\n```tsx\n// old one keeps living, untouched, so nothing breaks\nexport { DataTable } from './data-table-legacy';\n// new one ships next to it under a clear name\nexport { DataTableV2 } from './data-table-v2';\n```\n\nIt's the same expand/migrate/contract shape, just one level up. Both exist. Features migrate to `DataTableV2` on their own schedule, each migration a small reviewable change instead of a codebase-wide flip. When the last import of the legacy one is gone, you delete it. The name `V2` is ugly on purpose â it's a temporary state that's supposed to disappear, and its ugliness is a standing reminder that the migration isn't finished. If `DataTableV2` is still around in a year, that's a signal, not a feature.\n\n## Step four: contract â actually delete the old thing\n\nThis is the step everyone skips, and skipping it quietly undoes all the discipline that came before. Expand and migrate without contract just leaves you with *two* ways to do everything, forever, plus a warning nobody reads anymore. The deprecation was a promise to remove the old path. Contract is keeping it.\n\nThe good news is that after the additive change, the deprecation, and the codemod, contract is almost anticlimactic. You check that usage has actually hit zero â and you gate it, don't eyeball it:\n\n```bash\n# fails CI if a single old-style usage survives\ngrep -rn 'color=' src/ --include='*.tsx' && echo \"still used â not safe to remove\" && exit 1\n```\n\nWhen that returns nothing, you delete the `color` prop, the mapping, the warning, and the ESLint rule in one small commit. The component gets *simpler* â which is the tell that you did the whole thing right. A migration that ends with more code than it started with wasn't a migration, it was an accretion.\n\n## What this really changes\n\nThe thing I want to leave you with isn't the codemod or the deprecation tag â it's the reframing. Once you stop seeing \"change a shared component\" as one scary event and start seeing it as expand, migrate, contract, the fear mostly evaporates. Not because the work got smaller, but because every step is now individually safe and individually revertible. You're never one merge away from a broken build. You're always in a known-good state that happens to support two ways of doing something for a while.\n\nAnd honestly, the willingness to make this move is a large part of what separates a component library people trust from one they're afraid of. A kit whose owners can evolve it safely keeps getting better. A kit whose owners are scared to touch it ossifies â every rough edge becomes permanent because fixing it feels too risky. The techniques here are what buy you the confidence to keep improving the thing everyone depends on.\n\nWhich raises the harder version of the same problem, and where I want to go next: it's one thing to evolve a component inside *one* repo where you can grep every call site. It's another to keep a shared UI kit in sync across *several* applications that each pull it in as a versioned package â where you can't see the call sites, can't run a codemod across them, and \"just delete the old prop\" can break an app you don't even have checked out. That's the next article.\n\nIf you've got a shared component you've been too scared to change, I'd genuinely like to hear what's holding it back â is it the call-site count, a missing safety net, or just that nobody's allowed to own it? Reach out and tell me which.\n"},{id:"react-playbook-headless-components",title:"Headless Components: Give Away the Behavior, Keep None of the Markup",excerpt:"The most reusable UI code I write renders nothing at all. Headless components, render props, and hooks-as-API are three names for one idea: separate what a component does from how
1it looks â and hand over the behavior without a single opinion about the markup.",tags:["React","Architecture","React Playbook"],date:"Jul 07, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-headless-components/cover.png",content:"\nHere's a thing that took me too long to internalize: the most reusable UI code I've ever written renders nothing. No `<div>`, no class name, no markup at all. It just computes behavior and hands it to whoever called it. That sounds backwards â a UI component that produces no UI? â but it's the idea behind every component library that survived contact with more than one design system.\n\n[Compound components](/blog/react-playbook-compound-components) took us most of the way there: let the caller control structure while the component owns the coordination. But a compound component still ships markup. `<Select.Option>` renders a `div`, picks a class, decides on padding. The moment two products with different designs both want that select behavior, that markup becomes a fight. Headless components are the answer to \"what if the component gave up the markup entirely and kept only the behavior.\"\n\n## The coupling nobody notices until it hurts\n\nThink about what a `<Combobox>` actually does. It manages an open/closed state. It tracks the highlighted option. It handles arrow keys, Enter, Escape, typeahead. It wires up the ARIA attributes so screen readers announce it correctly. It filters options as you type. That's a *lot* of genuinely hard logic â the kind with edge cases you'll get wrong the first three times.\n\nAnd then, almost as an afterthought, it renders some divs with some classes.\n\nThe hard part â the behavior â is completely universal. Every combobox everywhere needs the same keyboard handling and the same accessibility wiring. The easy part â the markup and styles â is completely specific. Your combobox looks nothing like mine. Yet a normal component welds those two together, so when I want your combobox's excellent keyboard handling but my design, I can't have it. I either fork your styles or reimplement your logic. Both are bad, and I've done both.\n\n> The behavior is universal. The markup is personal. A normal component fuses them and forces you to take both or neither.\n\nHeadless components unfuse them. They keep all the hard, universal behavior and give away *zero* markup, letting you supply the personal part yourself.\n\n## Three shapes of the same idea\n\n\"Headless\" isn't one API. It's a family of three techniques that all separate behavior from rendering. They're worth knowing as a set, because you'll pick between them by feel.\n\n**Render props / children-as-a-function.** The component computes state and calls *your* function to render. It decides nothing about looks.\n\n```tsx\n<Toggle>\n {({ on, toggle }) => (\n <button onClick={toggle} className={on ? 'bg-[#22c55e]' : 'bg-gray-300'}>\n {on ? 'On' : 'Off'}\n </button>\n )}\n</Toggle>\n```\n\n`Toggle` owns the on/off logic. You own every pixel. It renders literally nothing except by calling your function.\n\n**Hooks-as-API.** The same idea, but the behavior is a hook instead of a component. This is the modern default, and honestly the cleanest â no wrapper component in the tree at all, just logic you call and props you spread.\n\n```tsx\nfunction ColorSwatch() {\n const toggle = useToggle();\n return (\n <button\n onClick={toggle.toggle}\n aria-pressed={toggle.on}\n className={toggle.on ? 'bg-[#22c55e]' : 'bg-gray-300'}\n >\n {toggle.on ? 'On' : 'Off'}\n </button>\n );\n}\n```\n\n**Prop getters.** For complex widgets, the hook hands you functions that return the props you need to spread onto your elements â including all the fiddly ARIA and event wiring you'd never remember to add yourself.\n\n```tsx\nfunction Search() {\n const { getInputProps, getMenuProps, getItemProps, items, isOpen } = useCombobox({ items: options });\n return (\n <div>\n <input {...getInputProps()} />\n <ul {...getMenuProps()}>\n {isOpen && items.map((item, i) => (\n <li key={item.id} {...getItemProps({ item, index: i })}>{item.label}</li>\n ))}\n </ul>\n </div>\n );\n}\n```\n\nYou wrote the `input`, the `ul`, the `li` â your elements, your styles. But `getInputProps()` spread the ARIA roles, the keyboard handlers, the focus management onto them. You got the world-class behavior and kept total control of the markup. That's the whole promise in one code block, and it's why libraries like Downshift, React Aria, TanStack Table, and the Radix primitives are built exactly this way.\n\n## Why this is dependency injection wearing a costume\n\nIf this feels familiar, it should. Back in the [composition and DI article](/blog/react-playbook-composition-and-di) I argued that a component should be handed its dependencies instead of reaching out to grab them. Headless components are that same principle turned inside out: instead of injecting *data or services* into a component, you're injecting the *rendering* into a behavior. The behavior doesn't reach out and decide how things look â you hand the looks in.\n\nThat's why the same benef
1its show up. A headless `useCombobox` is trivially testable, because you can drive its logic without rendering any particular UI. It's endlessly reusable, because it makes no assumption about markup. And it lets you change your mind about design without touching the logic, because the logic never knew what it looked like in the first place. Separation of concerns stops being a slogan and becomes a physical fact: the concern that's hard and universal lives in one place, the concern that's easy and specific lives in yours, and they meet through a thin, explicit interface.\n\n## The cost, honestly\n\nI'm not going to pretend this is free, because the failure mode is real and I've watched teams walk into it. Headless components move work *to the caller*. When you use a styled `<Button>`, you get a button and you're done. When you use a headless toggle, you have to render the button, style it, and handle the states yourself. Multiply that across every component and a fully headless design system can feel like it gives you a pile of hooks and a homework assignment.\n\nSo the real-world answer is usually a layer cake, not a religion. Headless primitives at the bottom â the hard behavior, unstyled. A *styled* layer on top of them â your design system's actual `<Button>`, `<Combobox>`, `<Select>`, built once on the headless base and used everywhere. Most of your app imports the styled layer and never thinks about the headless one. The headless layer is there for the 10% of cases where the styled component doesn't fit and you need to drop down and rebuild the look while keeping the behavior. You get convenience by default and an escape hatch when you need it â instead of choosing between a rigid component library and reinventing keyboard navigation.\n\nThat layering is also the answer to the [UI-kit-across-many-apps problem](/blog/building-design-systems-that-scale) I've written about before: the behavior is shared once at the headless layer, and each app skins it without forking the logic. Same accessibility, same keyboard handling, different paint.\n\n## What I actually reach for\n\nMy rule of thumb is short. If a component's *behavior* is hard and its *look* varies â combobox, date picker, dropdown menu, data table, anything with keyboard and ARIA â I want it headless underneath, styled on top. If a component's look is fixed and its behavior is trivial, a plain styled component is right and headless is overkill. Most of a codebase is the second kind. But the handful of components that are the first kind are exactly the ones that cause the most pain when they're not headless, because they're the ones everyone needs slightly differently.\n\nThe mindset shift, though, is the real takeaway â the same one compound components started. You stop thinking \"I'm building a combobox\" and start thinking \"I'm building combobox *behavior*, and the combobox is just one thing someone might render with it.\" Once you see UI logic as separable from UI markup, you can't unsee it, and you start noticing how much supposedly-reusable code isn't reusable at all â not because the logic is wrong, but because it's handcuffed to one specific set of divs.\n\nThat wraps the two most important component patterns â compose the structure, then hand off the behavior. Next in this run I want to get at the unglamorous half of building shared components: how you *change* one that dozens of places already depend on, without a giant risky rewrite â deprecation paths, codemods, and updating call sites without fear. If you've got a component library, go find the one component everyone reimplements slightly differently. That's almost always a behavior that should have been headless â tell me which one it is in yours.\n"},{id:"react-playbook-compound-components",title:"Compound Components: Designing an API, Not Just a Component",excerpt:"The moment a component grows past a handful of props, you're not writing a component anymore â you're designing an API. Compound components are how you keep that API flexible without drowning it in configuration props.",tags:["React","Architecture","React Playbook"],date:"Jul 03, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-compound-components/cover.png",content:"\nYou build a `<Select>`. It starts clean â options in, value out. Then the requests arrive. Someone needs a divider between groups. Someone needs an icon next to certain options. Someone needs a sticky \"create new\" row at the bottom. Someone needs a disabled option with a tooltip explaining why. Each request adds a prop: `groups`, `renderIcon`, `footerSlot`, `disabledReason`. Six months later your `<Select>
1` takes twenty-three props, half of them undocumented, and every new requirement means editing the component and praying you didn't break the other twenty-two use-cases.\n\nThis is the prop-explosion death spiral, and it happens to every successful shared component. The problem isn't that you added props badly. It's that props were the wrong tool for the job somewhere around request number three. What you actually needed was to stop configuring a component and start *composing* one. That's what compound components are.\n\nThe last few articles have been about boundaries and where code lives. This one starts a run on the *patterns* you build inside those boundaries â the techniques that decide whether the components you write get reused or quietly rewritten. Compound components are the first one because they're the most common thing people get wrong.\n\n## The wall you hit with props\n\nProps are perfect right up until the caller needs control over *structure*. A boolean, a string, a callback â props handle those beautifully. But the moment the caller wants to decide *what goes where* â the order of things, what sits between them, what wraps them â props turn into a configuration language you're now forced to invent and maintain.\n\n```tsx\n// the prop-explosion version â every layout decision is a prop\n<Select\n options={options}\n value={value}\n onChange={setValue}\n renderOption={(o) => <Icon name={o.icon} />}\n groupBy=\"category\"\n groupHeaderRenderer={(g) => <strong>{g}</strong>}\n footer={<CreateNewRow />}\n showDividers\n dividerAfter={[2, 5]}\n/>\n```\n\nLook at that `dividerAfter={[2, 5]}`. That's the smell. You're passing array indices to describe where a line should appear, because the component owns the structure and the caller has to negotiate with it through props. Every new layout wish is a new prop, a new branch inside the component, and a new way for the component to break. The component knows too much about every caller, and every caller knows too much about the component's internal prop vocabulary.\n\n> When callers start passing you layout instructions as props, they're asking for composition. Give it to them.\n\n## Compound components invert the control\n\nA compound component flips the relationship. Instead of one component that takes a pile of props describing structure, you expose a *set* of components that the caller arranges themselves. The classic shape everyone knows is the native `<select>`:\n\n```html\n<select>\n <option>One</option>\n <optgroup label=\"More\">\n <option>Two</option>\n </optgroup>\n</select>\n```\n\nYou don't pass `<select>` a `groups` prop. You *compose* `<option>` and `<optgroup>` inside it. The React version of that idea looks like this:\n\n```tsx\n<Select value={value} onChange={setValue}>\n <Select.Option value=\"one\">One</Select.Option>\n <Select.Divider />\n <Select.Group label=\"More\">\n <Select.Option value=\"two\" icon={<StarIcon />}>Two</Select.Option>\n </Select.Group>\n <Select.Footer><CreateNewRow /></Select.Footer>\n</Select>\n```\n\nEvery one of those twenty-three props just became a component the caller places wherever they want. Need a divider after the second item? Put a `<Select.Divider />` there. Need an icon? Pass it to that one `<Select.Option>`. The component stopped owning the structure, and the caller took it back â which is exactly what they were trying to do with all those props. You didn't add flexibility by adding configuration. You added it by *removing* configuration and letting composition do the work.\n\n## The part that makes it actually work: shared state\n\nHere's the question that trips people up: if `<Select.Option>` is a separate component sitting who-knows-how-deep inside the tree, how does clicking it tell `<Select>` about the new value? You can't pass props down â you don't own the JSX in between. The answer is React Context, and this is the one piece of machinery that turns a pile of dotted components into a real compound component.\n\n```tsx\nconst SelectContext = createContext<{\n value: string;\n onChange: (v: string) => void;\n} | null>(null);\n\nfunction Select({ value, onChange, children }
1: SelectProps) {\n return (\n <SelectContext.Provider value={{ value, onChange }}>\n <div role=\"listbox\">{children}</div>\n </SelectContext.Provider>\n );\n}\n\nfunction Option({ value, icon, children }: OptionProps) {\n const ctx = useContext(SelectContext);\n if (!ctx) throw new Error('<Select.Option> must be used inside <Select>');\n const selected = ctx.value === value;\n return (\n <div\n role=\"option\"\n aria-selected={selected}\n onClick={() => ctx.onChange(value)}\n className={selected ? 'bg-[#22c55e]/10' : ''}\n >\n {icon}\n {children}\n </div>\n );\n}\n\n// attach the children as properties â this is what enables the dot syntax\nSelect.Option = Option;\nSelect.Divider = () => <div role=\"separator\" className=\"my-1 border-t border-gray-200\" />;\nSelect.Group = Group;\nSelect.Footer = Footer;\n```\n\nThe parent puts the shared state into context. Every child reads from context, so it doesn't matter how deeply it's nested or what the caller wraps it in â an `<Option>` three divs down still knows the current value and can report a click. That context is the invisible wire connecting components the caller arranged. Props flow explicitly to configure *one* option (its icon, its value); context flows implicitly to coordinate *all* of them. That division is the whole trick.\n\nNotice the `throw` in `Option`. Because these components only make sense together, a compound component should fail loudly when used apart. A bare `<Select.Option>` with no `<Select>` around it is a bug, and catching it with a clear error is far kinder than letting it silently render nothing.\n\n## When it earns its place â and when it doesn't\n\nCompound components are not free. They're more code, more indirection, and a context that has to exist. So I don't reach for them by default. I reach for them when three signals show up together.\n\n**The caller needs to control structure.** If people keep asking to reorder parts, inject things between parts, or wrap parts â that's the tell. Structure control is what props are bad at and composition is good at.\n\n**The parts are meaningless alone but meaningful together.** `Tabs`/`TabList`/`Tab`/`TabPanel`. `Accordion`/`AccordionItem`. `Menu`/`MenuItem`/`MenuSeparator`. None of those children make sense on their own, and all of them need to coordinate through shared state. That's the exact shape compound components fit.\n\n**There's real shared state to coordinate.** If the children don't need to know about each other or a common value, you don't need the context, and you probably don't need this pattern â you just need components that accept `children`.\n\nAnd when *not* to: a component with two or three stable props and no structural variation is perfect as a plain component. Wrapping a `<Button>` in a compound API is pure ceremony. The pattern earns its complexity only when the alternative is prop explosion. If you're not drowning in configuration props, you don't have the problem this solves.\n\n## The mental shift that matters\n\nThe reason I wanted to open the patterns run with this one is that compound components force a change in how you think, and that change outlasts the specific technique. The first time your component grows past a handful of props, you're not building a component anymore. You're designing an API that other people â including future you â will program against. And APIs are judged by how gracefully they handle the requests you didn't anticipate.\n\nA prop-based component handles the unanticipated request by growing a new prop, and eventually collapses under its own configuration surface. A compound component handles it by letting the caller compose a solution you never had to predict â the divider you didn't plan for, the footer you didn't imagine â without you touching the component at all. That's the difference between a component people configure and a component people *build with*. The first one you maintain forever. The second one mostly maintains itself.\n\nNext I want to push this one step further, to the pattern that takes the same idea and removes the last assumption a compound component still makes â that *you* own the markup at all. Headless components, render props, hooks-as-API: handing over behavior while giving up the UI entirely. If you've got a shared component in your codebase with more than a dozen props, go count them, then ask how many are really the caller trying to control structure. I'd bet the number is higher than you'd like â tell me what you find.\n"},{id:"react-playbook-container-presentational",title:"Is Container vs Presentational Dead? What Survived the Hooks Era",excerpt:"The pattern that taught a generation to split 'smart' from 'dumb' components got quietly declared obsolete when hooks arrived. It wasn't. The folder convention died. The idea underneath it is more useful now than ever â including in Server Components.",tags:["React","Architecture","React Playbook"],date:"Jun 30, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-container-presentational/cover.png",content:"\nSomewhere around 2019, \"container vs presentational components\" got quietly filed under *obsolete*. Hooks had landed, the man who popularized the pattern had publicly walked it back, and every other blog post declared that `containers/` and `components/` folders were a relic. Move on, we have `useEffect` now.\n\nThat take was half right, and the half it got wrong is the interesting half. The *folder convention* deserved to die. The *idea* it was pointing at â separate the component that knows about the world from the component that just renders â didn't die at all. It went underground, changed shape, and quietly became one of the most useful instincts you can have. It's the exact split I kept landing on in the [composition and DI article](/blog/react-playbook-composition-and-di) without naming it. Time to name it.\n\n## What the pattern actually said\n\nFor anyone who missed the original: the classic pattern split components into two kinds.\n\n**Container components** knew how the app worked. They fetched data, held state, talked to the store, ran side effects â and rendered almost nothing themselves. **Presentational components** knew how things *looked*. They took props, rendered markup, fired callbacks, and knew nothing about where their data came from or where it went.\n\n```tsx\n// the old shape â a container that fetches, and a dumb view it feeds\nclass UserListContainer extends React.Component {\n state = { users: [], loading: true };\n componentDidMount() {\n fetchUsers().then((users) => this.setState({ users, loading: false }));\n }\n render() {\n return <UserList users={this.state.users} loading={this.state.loading} />
1;\n }\n}\n\nfunction UserList({ users, loading }) {\n if (loading) return <Spinner />;\n return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;\n}\n```\n\nThe value was real: `UserList` was trivial to test and reuse because it was just a function of its props, and the messy stateful part lived in one place. The problem was equally real: it was *ceremony*. Every stateful component needed a class wrapper around a function, entire files existed only to fetch-and-pass, and the two halves were welded together one-to-one anyway. You wrote twice the components for a separation that was mostly bookkeeping.\n\n## What hooks actually killed\n\nThen hooks arrived, and here's the precise thing they changed: **you no longer need a separate component to hold state or effects.** The whole mechanical reason containers existed â \"a function component can't have state, so wrap it in a class\" â evaporated. A single function component can now fetch, hold state, run effects, *and* render:\n\n```tsx\nfunction UserList() {\n const { data: users, isLoading } = useQuery(['users'], fetchUsers);\n if (isLoading) return <Spinner />;\n return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;\n}\n```\n\nOne component. No container. And this is genuinely better for most cases â the ceremony is gone. So when people say \"hooks killed container/presentational,\" this is what they mean, and they're right: hooks killed the *requirement* to physically split components just to have state.\n\nBut look closely at what they did *not* kill. `UserList` above now knows how to fetch. It reaches out and grabs its data â the exact reach-out anti-pattern from last time. It's convenient, and for a component you'll only ever use in one place, convenient is correct. The trouble is that people took \"hooks removed the ceremony\" and heard \"the separation was pointless.\" Those are not the same sentence.\n\n## The idea that refused to die\n\nStrip away the class wrappers and the folder names, and the pattern was never really about containers. It was about a *boundary*: one side knows about the outside world â data, state, side effects, where things come from and go â and the other side just takes inputs and renders. Hooks removed the boilerplate for drawing that boundary. They did not remove the reasons you'd want to.\n\nYou still want it every single time you need to:\n\n- **test rendering without a backend** â a pure view takes props, no mock server required;\n- **reuse the same look with different data** â the same table fed by a query, a websocket, or fixtures;\n- **show it in Storybook** â a component that fetches its own data can't live in a story without a whole app around it;\n- **let a designer or a different feature reuse the visuals** without inheriting your data layer.\n\nSo the modern move isn't `containers/` folders. It's this: **write the one-component version by default, and split along the data/render seam the moment you need the render half to stand on its own.** Same `UserList`, split not because a rule said so but because you now have a reason:\n\n```tsx\n// the \"container\" is just a function that fetches and delegates â no class, no folder\nfunction UserListView({ users, isLoading }: { users: User[]; isLoading: boolean }) {\n if (isLoading) return <Spinner />;\n return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;\n}\n\nfunction UserList() {\n const { data = [], isLoading } = useQuery(['users'], fetchUsers);\n return <UserListView users={data} isLoading={isLoading} />;\n}\n```\n\nNotice there's no class, no `containers/` directory, no naming ritual. Just a component that knows about the world wrapping a component that doesn't. The pattern survived; the ceremony didn't. That's the whole story of the \"death\" of container/presentational.\n\n## Where hooks made it fuzzier â and better\n\nThe honest update is that hooks also gave us a *third* option the old pattern didn't have, and it's often the best one: **extract the world-knowledge into a custom hook instead of a component.**\n\n```tsx\n// the \"container logic\" is now a hook, not a wrapper component\nfunction useUserList() {\n const { data = [], isLoading } = useQuery(['users'], fetchUsers);\n return { users: data, isLoading };\n}\n\nfunction UserList() {\n const { users, isLoading } = useUserList();\n if (isLoading) return <Spinner />;\n return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;\n}\n```\n\nNow the \"smart\" part is reusable on its own â any component can call `useUserList` â and the rendering stays simple. This is the container/presentational split turned sideways: instead of *smart component + dumb component*, it's *smart hook + component that renders*. For a lot of cases this is the sweet spot, because the reusable unit you actually wanted was the data logic, not a wrapper component. The boundary is still there. It just moved from a component seam to a hook seam.\n\nSo the real menu today isn't \"container or not.\" It's three options, and you pick by what you need to reuse or isolate:\n\n1. **One component** that does it all â the default, for anything single-use.\n2. **Smart hook + component** â when the *logic* is what you want to reuse or test.\n3. **Fetching component + pure view** â when the *rendering* is what you want to reuse, test, or hand to Storybook.\n\n## Server Components made the split matter again\n\nHere's the twist nobody saw coming when they were busy declaring the pattern dead. React Server Components brought back a *hard, physical* version of exactly this boundary â and this time the framework enforces it.\n\nA Server Component runs on the server, can be async, can hit the database directly, and ships no JavaScript to the browser. A Client Component (`'use client'`) runs in the browser and is the only thing that can hold state or handle events. The natural, idiomatic shape is: **Server Components fetch and compose the data; Client Components take that data as props and handle interactivity.**\n\n```tsx\n// app/users/page.tsx â Server Component: the \"container\", now literally server-side\nexport default async function UsersPage() {\n const users = await db.user.findMany(); // no client JS, runs on the server\n return <UserList users={users} />;\n}\n\n// UserList.tsx â 'use client': the interactive, presentational half\n'use client';\nexport function UserList({ users }: { users: User[] }) {\n const [query, setQuery] = useState('');\n const shown = users.filter((u) => u.name.includes(query));\n return (/* search box + list */);\n}\n```\n\nRead that and tell me it isn't container/presentational wearing a new uniform. The server component *knows about the world* â it fetches, it composes. The client component *takes props and renders*, plus handles the interactivity that has to live in the browser. The pattern everyone buried in 2019 is now baked into the newest thing React ships, except now the boundary isn't a convention you can ignore â it's a wall the runtime draws for you, and crossing it wrong is a build error.\n\n## What I actually believe about it\n\nThe pattern isn't dead and it isn't alive in its original form either. What happened is more useful: the *rule* dissolved and the *instinct* got sharper. I don't make `containers/` folders. I don't wrap functions in classes. I don't split a component just because a style guide from 2016 says smart and dumb should be separate files.\n\nWhat I do â every time â is keep one question live while I write a component: *does this thing know about the world, or does it just render?* When the answer is \"both, and I need one of those halves on its own,\" I split along that seam, and I reach for whichever tool fits â a hook, a pure view, or a Server/Client boundary. The genius of the old pattern was never the folders. It was teaching a generation to notice that seam at all. That part was never obsolete. It just stopped needing a name to be worth doing.\n\nThat closes out this run on architecture and boundaries â where code lives, which way it depends, how to size and wire a component, and now where the smart/dumb line actually falls. Next I want to move from *structure* to *patterns*: the reusable-component techniques â [compound components](/blog/react-playbook-compound-components), headless UIs, behavior-as-API â that decide whether the things you build get used or quietly rewritten. If you've still got a `containers/` folder in a codebase somewhere, don't rename it in a panic â just notice whether the split it's drawing is one you'd still draw today. Tell me what you find; I'd bet half of them are pulling their weight and half are pure ceremony.\n"},{id:"react-playbook-composition-and-di",title:"How Big Should a Component Be? Composition, Decomposition, and Handing Over Dependencies",excerpt:"Split a component too early and you get a maze of tiny files. Split too late and you get a 600-line monster. The real question isn't size â it's who owns which depen
1dency, and whether the component reaches for it or is handed it.",tags:["React","Architecture","React Playbook"],date:"Jun 26, 2026",readTime:"12 min read",coverImage:"/blog/react-playbook-composition-and-di/cover.png",content:"\nEvery code review eventually gets the comment: \"this component is too big, split it up.\" It's good advice roughly half the time. The other half, someone dutifully chops a working 200-line component into eight files, and now understanding it means opening all eight, following props through four of them, and reassembling the whole thing in your head. The component wasn't fixed. It was smeared.\n\nSo \"split it up\" isn't the rule. Size is a symptom, not the disease. The real question underneath â the one the last three articles have been circling â is about *dependencies*: what does this component need to do its job, who gives it those things, and does the component go reach for them itself or get handed them? Get that right and the size mostly sorts itself out. Get it wrong and no amount of splitting helps.\n\n## Decomposition isn't about line count\n\nLet me start with the part people get backwards. You don't split a component because it's long. You split it because it's doing more than one *thing* â because two different reasons to change are living in the same file.\n\nHere's a component that's long but shouldn't be split:\n\n```tsx\nfunction PolicyDetails({ policy }: { policy: Policy }) {\n // 180 lines of one coherent thing: rendering the details of a policy.\n // Header, status, coverage table, footer. All one job. All change\n // together, for the same reason (\"we redesigned the policy view\").\n}\n```\n\nIt's 180 lines and I'd leave it alone. There's one reason for it to change, and everything in it changes for that reason. Splitting it into `<PolicyHeader>`, `<PolicyStatus>`, `<PolicyFooter>` buys you nothing but four files to keep in sync â the classic mistake I wrote about in [the smeared component](/blog/smeared-component). Now here's a component that's *short* but absolutely should be split:\n\n```tsx\nfunction UserBadge({ userId }: { userId: string }) {\n const { data: user } = useQuery(['user', userId], () => fetchUser(userId));\n const theme = useContext(ThemeContext);\n const track = useAnalytics();\n\n useEffect(() => { track('badge_shown', { userId }); }, [userId]);\n\n if (!user) return <Spinner />;\n return <span style={{ color: theme.accent }}>{user.name}</span>;\n}\n```\n\nForty lines, and it's doing four unrelated jobs: fetching data, reading theme, firing analytics, and rendering. Four reasons to change, tangled together. This one I'd split â not because it's big, but because it's *mixed*.\n\nThat's the actual rule: **decompose along reasons to change, not along line count.** One component, one job, one reason to change. A long file doing one thing is fine. A short file doing four things is a problem waiting to surprise you.\n\n## The reach-out anti-pattern\n\nLook again at `UserBadge`. The thing that makes it hard to reuse and impossible to test in isolation isn't its length. It's that it *reaches out* and grabs everything it needs from the ambient environment: it calls `fetchUser` directly, pulls theme from a context, grabs the analytics client from a hook. It's wired straight into the world.\n\nThis is the component equivalent of an arrow pointing the wrong way â the exact problem from the [dependency-direction article](/blog/react-playbook-module-dependencies), now one level down, inside a single component. `UserBadge` claims to be a small presentational thing, but it depends on your data layer, your theme system, and your analytics pipeline. Drop it into a Storybook story or a test and it explodes, because it demands a whole app around it just to render a name.\n\nYou feel this the moment you try to reuse it. \"I just want to show a user's name in a different color\" â but you can't, because the color is hard-wired to `ThemeContext.accent`, and the name is hard-wired to *this* query. The component grabs its dependencies instead of accepting them, so it only works in the one spot it was born in.\n\n## Hand it over instead\n\nThe fix is old and boring and it's called dependency injection, which sounds like a Java framework and is actually just this: **don't let a component fetch its own dependencies â hand them in.** On the frontend, \"inject\" mostly means \"pass as a prop\" or \"provide through context,\" and the entire idea is that the component stops knowing *where* things come from.\n\n```tsx\n// UserBadge now just renders. It's handed everything it needs.\nfunction UserBadge({ name, color }: { name: string; color: string }) {\n return <span style={{ color }}>{name}</span>;\n}\n```\n\nThat's it. It doesn't fetch, doesn't read context, doesn't track. It takes a name and a color and renders. Now it works in a test with two strings, works in Storybook with
1no providers, works anywhere. The fetching, theming, and analytics didn't disappear â they moved *up* to a component whose job is exactly that wiring:\n\n```tsx\nfunction ConnectedUserBadge({ userId }: { userId: string }) {\n const { data: user } = useQuery(['user', userId], () => fetchUser(userId));\n const theme = useContext(ThemeContext);\n const track = useAnalytics();\n\n useEffect(() => { track('badge_shown', { userId }); }, [userId, track]);\n\n if (!user) return <Spinner />;\n return <UserBadge name={user.name} color={theme.accent} />;\n}\n```\n\nWe split `UserBadge` â but notice *how* we split it. Not into \"the top half and the bottom half.\" Along a dependency seam: one component that knows about the outside world (fetching, context, side effects), and one that knows nothing and just renders what it's given. That seam is the useful cut. The line-count cut isn't.\n\n## Injection is a spectrum, not a switch\n\nYou don't have to reach for a DI container or some ceremony. Frontend dependency injection is mostly choosing, per dependency, how far to push it out of the component. From least to most flexible:\n\n**Hard-coded (no injection).** The component imports and calls the thing directly. Fine for genuinely stable, app-agnostic dependencies â a component importing `clsx` or a pure `formatDate` doesn't need those injected. Not everything is a dependency worth managing.\n\n**Props.** The default and the one you'll use most. Pass the value, the callback, the data. Explicit, type-checked, trivially testable. If you're not sure how to inject something, pass it as a prop and stop overthinking it.\n\n```tsx\n// the data source is injected â the list doesn't know or care where rows come from\nfunction DataTable<T>({ rows, onRowClick }: { rows: T[]; onRowClick: (row: T) => void }) { /* ... */ }\n```\n\n**Context.** For dependencies that are truly cross-cutting and tedious to thread â theme, current user, a query client, an analytics sink. Context is injection at a distance: the component pulls from context, but *what's in that context* is decided at the provider, so tests and stories can hand it a fake. The danger is overusing it â every context dependency is an invisible input that doesn't show up in the props, and a component that reads six contexts is just as wired-in as one that imports six modules. Use context for the few things that are genuinely app-wide, and props for everything else.\n\n**Render props / children as a function / slots.** Inject *behavior or markup*, not just values. This is how you build something reusable without it knowing what it renders â a `<List>` that takes a `renderItem`, a `<Form>` that takes children. It's dependency injection where the dependency is a piece of UI.\n\nThe skill isn't picking one. It's matching each dependency to the lightest mechanism that still lets you swap it when you need to. Most things: props. A few app-wide things: context. Behavior you want to vary: a function. Genuinely fixed, generic utilities: just import them.\n\n## The payoff you actually feel\n\nThis all sounds abstract until the day it isn't. Three moments where injecting dependencies instead of grabbing them pays for itself:\n\n**Testing.** A component handed its dependencies is a component you test with plain values â no mocking the fetch layer, no wrapping in five providers, no fighting the framework. `render(<UserBadge name=\"Ada\" color=\"#22c55e\" />)` and you're done. The reach-out version needs a mock server and a provider tree just to assert on a name.\n\n**Reuse.** The whole reason the \"small\" `UserBadge` couldn't be reused was that it grabbed its color from one specific context. Hand the color in and it drops into any screen, any theme, any story. Injected dependencies are what make a component portable â which, not coincidentally, is the same [portability promise](/blog/react-playbook-shared-code) that decides whether something belongs in `shared/` at all.\n\n**Changing your mind.** Swap `fetchUser` for a different endpoint, replace the analytics provider, feed the table rows from a websocket instead of a query â and the leaf components don't change at all, because they never knew where their data came from. You only touch the wiring layer. The thing you were afraid to change turns out to have one owner, in one place.\n\n## The rule I actually carry around\n\nI've stopped asking \"is this component too big.\" It's the wrong question and it leads to smearing. The questions I ask instead are two: *how many reasons does this component have to change* (that tells me whether to split, and where), and *does it reach for its dependencies or get handed them* (that tells me whether it'll be testable and reusable, or welded to one spot).\n\nComposition, in the end, isn't about making things small. It's about making the seams fall in the right places â
1cutting along dependencies, not along line counts â and being deliberate about which component owns the wiring and which just renders what it's told. Do that and your big components stay big and fine, your small ones stay focused, and the word \"reusable\" starts meaning something instead of being a hope you wrote in a PR description.\n\nNext I want to take this exact idea â the split between \"knows about the world\" and \"just renders\" â and hold it up against the pattern that's been arguing about it for a decade: [container versus presentational components](/blog/react-playbook-container-presentational), and whether that split still makes sense now that we have hooks and Server Components. If you've got a component you're scared to reuse, look at what it reaches for instead of what it's handed. That list is usually the whole reason â tell me what's on yours.\n"},{id:"react-playbook-module-dependencies",title:"The Direction of the Arrows: Dependencies Are Your Real Architecture",excerpt:"You can draw whatever folder diagram you like. The honest picture of your app is which module imports which â the direction of the arrows. Get that wrong and no folder structure will save you.",tags:["React","Architecture","FSD","React Playbook"],date:"Jun 23, 2026",readTime:"12 min read",coverImage:"/blog/react-playbook-module-dependencies/cover.png",content:"\nDraw your app's architecture on a whiteboard and it always looks clean. Neat boxes, sensible names, tidy layers stacked top to bottom. Everyone nods. Then you open the code and it doesn't match the drawing at all â a UI component reaches into a data-fetching file, a \"shared\" helper imports a specific feature, two features import each other through a back door nobody remembers adding.\n\nThe whiteboard is the architecture you *wish* you had. The import statements are the architecture you *actually* have. And the gap between them is where most large React apps quietly rot.\n\nI've spent the last two articles on where code lives â the [`shared/` folder](/blog/react-playbook-shared-code) and [barrel files](/blog/react-playbook-barrel-files). This one is about the thing underneath both: not where a module sits, but which way it *points*. Who is allowed to import whom. Because that â the direction of the arrows â is the real shape of your app.\n\n## Your folders lie, your imports don't\n\nHere's the uncomfortable part. Folder structure is a suggestion. You can put a file in `shared/` and it means nothing about how coupled it is â what matters is what it imports and who imports it. Two apps with identical folder trees can have completely different architectures, because architecture isn't the tree. It's the graph.\n\nEvery `import` statement is a directed edge: \"this module depends on that one.\" Collect all of them and you get your dependency graph â the one true map of your app. And that map answers the questions folders can't:\n\n- If I change this file, what breaks? (everything that points *at* it)\n- Can I delete this feature cleanly? (only if nothing outside points into it)\n- Why is this \"small\" component impossible to reuse? (because it points at half the app)\n- Why did one route pull in the entire bundle? (follow the arrows â you'll find a bad one)\n\nYou already met that last one in the barrel article. That wasn't really a barrel problem. It was an *arrow* problem â an import pointing somewhere it shouldn't, and a barrel making it easy.\n\n> Folders describe intent. Imports describe reality. When they disagree, reality wins.\n\n## Arrows have a correct direction\n\nThe single most useful architectural rule I know fits in one sentence: **dependencies should point in one direction â from specific to general, from volatile to stable, never back.**\n\nThink of your app in rough layers, top to bottom:\n\n```\napp â wires everything together\nfeatures â \"edit policy\", \"create claim\" â business use-cases\nentities â domain models: Policy, Claim, User\nshared â generic, app-agnostic: Button, formatDate, http client\n```\n\nThe rule is simple: **arrows only ever point down.** A feature may import an entity. An entity may import from `shared`. `shared` imports *nothing* from above it â not an entity, definitely not a feature.\n\nWhy down and not up? Because the lower you go, the more stable and general the code is. `shared/Button` doesn't know your app sells insurance. `entities/Policy` knows about policies but not about the specific \"renew a lapsed policy\" screen. The `edit-policy` feature knows the most and changes the most. If a stable module (`Button`) points *up* at a volatile one (`edit-policy`), then every time that feature changes, your generic button is at risk â and it's no longer generic. You just welded the most reusable thing in your app to the least reusable one.\n\nThe moment `shared` imports a feature, the word \"shared\" becomes a lie. This is the mechanical reason the `shared/` folder turns into a junk drawer: not because people are messy, but because they let arrows point the wrong way, and an upward arrow is exactly how a generic folder gets chained to a specific feature.\n\n## The two arrows that kill you\n\nThere are two directions of arrow that do almost all the damage. Learn to see them and
1you've caught 90% of architectural decay.\n\n**The upward arrow â general depending on specific.** A shared utility importing a feature. An entity importing a screen. A design-system component importing app-specific business logic. Every one of these takes something that was supposed to be reusable and staples it to something that isn't.\n\n```ts\n// shared/lib/format.ts â a \"generic\" formatter\nimport { POLICY_STATUS_LABELS } from '@/features/edit-policy/constants';\n// ^^^^^^^^^^^^^^^^^^^^^^ upward arrow\n\nexport function formatStatus(code: string) {\n return POLICY_STATUS_LABELS[code] ?? code;\n}\n```\n\nThis looks harmless. It compiles, it works, the PR gets approved. But `shared/lib/format` now depends on `edit-policy`. Import that formatter anywhere â a totally unrelated screen â and you've dragged `edit-policy` in with it. \"Shared\" now means \"shares a dependency on one specific feature.\" The fix is to push the specific thing up to where it belongs and keep the shared thing ignorant:\n\n```ts\n// shared/lib/format.ts â knows nothing about policies\nexport function formatStatus(code: string, labels: Record<string, string>) {\n return labels[code] ?? code;\n}\n\n// features/edit-policy/... â the feature owns its own labels\nformatStatus(policy.status, POLICY_STATUS_LABELS);\n```\n\nThe generic module stayed generic. The specific knowledge stayed in the feature. The arrow points down again.\n\n**The sideways arrow â feature importing feature.** `edit-policy` reaches directly into `billing-dashboard`. Now you can't touch one without understanding the other, you can't delete either cleanly, and if `billing-dashboard` also imports `edit-policy` back, you've got a cycle â two modules that can only be understood as one tangled unit. Cross-feature imports are how a codebase where every feature was supposed to be independent becomes one where nothing is.\n\nThe fix isn't \"never let features talk.\" It's *how* they talk. Pull the shared concept down into an entity both can depend on, or lift the coordination up into the `app` layer that's allowed to know about both. Features don't import each other sideways; they meet in a layer above or below. Arrows down, never across.\n\n## A feature's public API is the only door in\n\nHere's what makes the \"arrows down\" rule enforceable instead of a vibe: **each feature exposes one public entry point, and nobody imports past it.**\n\n```\nfeatures/edit-policy/\n index.ts â the ONLY thing outsiders may import\n ui/EditPolicyForm.tsx\n model/useEditPolicy.ts\n model/validation.ts â private\n lib/mapPolicyDto.ts â private\n```\n\n```ts\n// features/edit-policy/index.ts â deliberate public surface\nexport { EditPolicyForm } from './ui/EditPolicyForm';\nexport { useEditPolicy } from './model/useEditPolicy';\n// validation, DTO mapping, internal helpers stay private\n```\n\nAnything outside the feature imports from `@/features/edit-policy` and nothing deeper. The insides â validation rules, DTO mapping, private hooks â are free to change, because from the outside they don't exist. This is the *good* use of a barrel I argued for last time: not to shorten imports, but to draw a wall with exactly one door in it.\n\nReach past that door â `import { validatePolicy } from '@/features/edit-policy/model/validation'` â and you've done two bad things at once: coupled to an internal that was never promised to stay, and created an arrow that no longer respects the feature's boundary. The public API is what turns \"arrows point down\" from a hope into a checkable rule. There's a door. You use the door.\n\n## Make the graph impossible to get wrong\n\nYou will not hold this in your head. Nobody does. On a real team, someone adds an upward import at 6pm on a Friday, it works, it ships, and the arrow is now part of your architecture forever. Good intentions don't scan the dependency graph. Tooling does.\n\nI let a linter own the rule. `eslint-plugin-import` with `no-restricted-imports`, or a boundaries plugin (`eslint-plugin-boundaries`, or the import rules in Biome), encoding the layer directions as actual errors:\n\n```js\n// eslint â shared may not import upward, features may not import each other\n'no-restricted-imports': ['error', {\n patterns: [\n { group: ['@/features/*', '@/entities/*'], message:\n 'shared/ must not import from features or entities â arrows point down only.' },\n ],\n}],\n```\n\nNow the wrong arrow doesn't get a code review debate. It gets a red squiggle before the commit. The architecture stops being a document people forget and becomes a constraint the tooling enforces. That's the whole game: an architectural rule that isn't mechanically enforced is a rule that's already being broken somewhere you haven't looked.\n\nAnd once the arrows are constrained, a dependency-graph visualiser (`madge`, `dependency-cruiser`, or your bundler's analyzer)
1stops being a curiosity and becomes a health check. Run `madge --circular` and it lists your cycles â every one a place where two modules secretly became one. A clean graph with arrows all pointing down is not an aesthetic preference. It's the difference between a change that touches one file and a change that touches forty.\n\n## Why this is the article under the others\n\nThe `shared/` folder turns into a junk drawer because people let it depend upward. Barrel files blow up your bundle because they make a bad arrow cheap to draw. Features become impossible to delete because they point sideways at each other. Every one of those is the same disease wearing a different symptom: **an arrow going the wrong way.**\n\nSo when I evaluate an architecture now, I don't start with the folder tree â that's the story the code tells about itself. I start with the graph: pull the imports, look at the direction of the arrows, and find the ones pointing up or sideways. Those arrows are the whole diagnosis. Fix them and the folders mostly sort themselves out. Ignore them and you can reorganize folders every quarter and still be stuck in the same tangle, because you were rearranging the boxes while the arrows â the thing that actually mattered â stayed exactly where they were.\n\nNext I want to turn from *where the arrows point* to *how big the boxes should be* â [composition versus decomposition](/blog/react-playbook-composition-and-di), and how to hand a component its dependencies instead of letting it reach out and grab them. If you want a fast, slightly alarming read on your own app tonight, run `madge --circular src` and count the cycles. Tell me the number â I'm curious whether you're prouder or more horrified than you expected.\n"},{id:"react-playbook-barrel-files",title:"Barrel Files: The Import You Love and the Bundle You Hate",excerpt:"A barrel file makes imports look beautiful and can quietly drag half your app into a bundle that only needed one function. Here's what index.ts actually does, when it earns its place, and when it's a trap wearing a clean interface.",tags:["React","Architecture","Performance","React Playbook"],date:"Jun 19, 2026",readTime:"10 min read",coverImage:"/blog/react-playbook-barrel-files/cover.png",content:"\nThere's a specific kind of import that feels like good engineering:\n\n```ts\nimport { Button, Card, Modal, Tooltip }
1 from '@/shared/ui';\n```\n\nOne line. One path. Clean. Compare it to the honest version:\n\n```ts\nimport { Button } from '@/shared/ui/Button';\nimport { Card } from '@/shared/ui/Card';\nimport { Modal } from '@/shared/ui/Modal';\nimport { Tooltip } from '@/shared/ui/Tooltip';\n```\n\nThe first one looks like a tidy public API. The second looks like clutter. So of course teams reach for the first â they add an `index.ts` that re-exports everything, and the whole folder gets a single front door. That file is a *barrel*, and it's one of the most quietly expensive habits in a React codebase. Not because it's wrong. Because it's right just often enough that people stop questioning it.\n\nI brought this up at the end of the [shared/ article](/blog/react-playbook-shared-code) and promised to come back to it. Here's the come-back.\n\n## What a barrel actually is\n\nA barrel is just a file â almost always `index.ts` â whose only job is to re-export other modules:\n\n```ts\n// shared/ui/index.ts\nexport { Button } from './Button';\nexport { Card } from './Card';\nexport { Modal } from './Modal';\nexport { Tooltip } from './Tooltip';\n```\n\nNow `@/shared/ui` resolves to this file, and consumers import named things from one place. The appeal is real: imports get shorter, the folder gets a defined surface, and you can move `Button.tsx` around internally without breaking any consumer, as long as the barrel keeps pointing at the right place. That last part â decoupling the internal file layout from the public import path â is a genuine architectural benefit, not just cosmetics. It's the same \"public API of a folder\" idea that makes feature boundaries work.\n\nSo far this sounds like something you'd want everywhere. That's exactly the trap.\n\n## The cost hides where you're not looking\n\nHere's the problem. When you write `import { Button }
1 from '@/shared/ui'`, you are not importing `Button`. You are importing the *barrel*, and the barrel imports **everything**. `Button`, `Card`, `Modal`, `Tooltip`, and whatever those pull in â the whole folder loads to hand you one component.\n\n\"But tree-shaking removes the unused ones,\" you say. Sometimes. Tree-shaking is real, but it's fragile, and a barrel is exactly the shape that breaks it. All it takes is one module in the barrel with a side effect â a file that runs code at import time, registers something, mutates a global, or is marked as having side effects in `package.json` â and the bundler can no longer prove the other exports are safe to drop. To stay correct, it keeps them. Your one-line `Button` import just pulled in `Modal`, its focus-trap library, and the animation dependency `Tooltip` uses.\n\nYou won't see it in code review. The import line looks identical whether the tree shakes cleanly or not. You'll see it in a bundle analyzer six months later, staring at a route that weighs 400KB and wondering why a page with two buttons is importing a charting library. The answer is almost always a barrel somewhere in the chain, quietly gluing unrelated things together.\n\nThere's a second cost that shows up sooner: build and dev-server speed. Every barrel is a fan-out. Import one thing through `@/shared/ui` and the module graph has to resolve every file the barrel touches, and every file *those* touch. In a big app with barrels re-exporting barrels re-exporting barrels, you get a dependency graph that explodes on almost any import. Cold dev starts crawl. Hot updates touch more than they should. The tooling spends its time walking a graph you created for the sake of a shorter import line.\n\n> A barrel trades a real cost you can't see for a cosmetic benefit you can.\n\n## The worst offender: the app-wide barrel\n\nThe pattern that does the most damage is the ambition to have one barrel at a high level that re-exports a whole layer:\n\n```ts\n// features/index.ts â please don't\nexport * from './edit-policy';\nexport * from './create-claim';\nexport * from './billing-dashboard';\nexport * from './user-settings';\n// ...twenty more\n```\n\nNow `import { EditPolicyForm } from '@/features'` reaches into a barrel that transitively imports *every feature in the app*. Import one feature and you've referenced all of them. Code-splitting â the entire point of which is that a route loads only what it needs â quietly stops working, because from the bundler's view everything is connected to everything through this one file. I've watched a lazy-loaded route pull in the whole app because a single import went through a top-level barrel instead of reaching for the specific module.\n\nThe `export *` form is especially nasty. It's not just eager; it forces the bundler to load a module just to find out what names it exports, so it can figure out which star-export a given name came from. Named re-exports (`export { X } from './x'`) are at least explicit. `export *` is a blank check.\n\n## When a barrel earns its place\n\nI'm not anti-barrel. I use them. The question is never \"barrels: yes or no.\" It's \"does this specific barrel pay for itself.\" A barrel earns its place when three things are true at once.\n\n**It wraps a genuine, stable public API.** A design-system package where `Button`, `Card`, and `Modal` are *the whole point* and consumers legitimately pull from across it â that's a real interface, and a barrel is the right way to express it. The barrel isn't hiding a mess; it's naming a surface that was always meant to be used as a whole.\n\n**It's small and internally cohesive.** A barrel over five related components in one feature is cheap â importing one probably means you're near the others anyway, and the fan-out is tiny. The cost of a barrel scales with what's behind it. Small folder, small cost.\n\n**Nothing behind it has surprising weight or side effects.** Pure, light modules tree-shake fine through a barrel. The danger is heavy or effectful modules â a component that drags in a 200KB dependency, a file that runs setup at import. One of those behind a barrel poisons every import that passes through it.\n\nMiss any of those and the barrel is working against you. A top-level `@/features` barrel fails all three: not a stable surface, not small, and full of heavy things. A `shared/ui/index.ts` over a real design system passes all three. Same mechanism, opposite verdict â which is the whole reason \"always use barrels\" and \"never use barrels\" are both wrong.\n\n## What I actually do\n\nMy rules are boring, which is how I like architecture rules.\n\n**Import from the specific file by default.** `import { Button }
1 from '@/shared/ui/Button'` is my normal. The import is three words longer and I never think about it again. No fan-out, no tree-shaking gamble, no accidental coupling.\n\n```ts\n// default: reach for the exact thing\nimport { Button } from '@/shared/ui/Button';\nimport { formatCurrency } from '@/shared/lib/money';\n```\n\n**Add a barrel only at a real boundary I've decided to publish.** A feature's public entry point, a design-system package's root â places where \"this is the surface, use it as one\" is a deliberate architectural statement, not a convenience. And when I do, I write explicit named re-exports, never `export *`:\n\n```ts\n// features/edit-policy/index.ts â a deliberate public surface\nexport { EditPolicyForm } from './ui/EditPolicyForm';\nexport { useEditPolicy } from './model/useEditPolicy';\n// internal helpers are NOT re-exported â they stay private\n```\n\nThat barrel is doing architectural work: it defines what the feature exposes and hides everything else, so the rest of the app can't reach into the feature's guts. That's a barrel as an *encapsulation boundary*, which is worth having. A barrel as a *typing convenience* usually isn't.\n\n**Never barrel a whole layer.** No `@/features`, no `@/entities` mega-barrel. Layers are not modules; they're categories. Giving a category a single import door is how code-splitting dies.\n\n**Verify with the analyzer, not with faith.** Tree-shaking is a claim, not a guarantee. Run a bundle analyzer, find the route that's too heavy, and trace it. More often than not the culprit is a barrel doing exactly what I described â and the fix is changing one import from the barrel to the specific file.\n\n## The real lesson isn't about barrels\n\nStrip away the mechanics and this is the same idea as the `shared/` article, pointed at a different symptom. A barrel is a public API for a folder. Public APIs are worth having when there's a real boundary to protect and a real surface to name. They're pure cost when you add them out of habit, to make imports prettier, over a pile of things that were never meant to be used as a unit.\n\nSo the question to ask isn't \"should this folder have an index.ts.\" It's \"does this folder have a public API worth defining â and am I willing to pay the fan-out to define it.\" When the answer is yes, barrel it, name the surface, and hide the rest. When the answer is \"I just want shorter imports,\" reach for the specific file and move on. The prettier import was never worth the bundle you couldn't see.\n\nNext in this stretch on architecture, I want to get at the thing underneath both of these articles: [how modules should *depend* on each other](/blog/react-playbook-module-dependencies) at all â the direction of the arrows, who's allowed to import whom, and why a dependency graph is the most honest picture of your app's health. If you've got a bundle that mysteriously balloons on one route, open the analyzer and go hunting for a barrel â then tell me what you found. I'm always curious how far the fan-out reached.\n"},{id:"react-playbook-shared-code",title:"The shared/ Folder Is Not a Place, It's a Promise",excerpt:"Every codebase grows a shared/ folder, and given enough time every shared/ folder becomes a junk drawer. Not because the team got lazy â because the folder was never given a rule. Here's why it rots, and how to keep it boring.",tags:["React","Architecture","FSD","React Playbook"],date:"Jun 16, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-shared-code/cover.png",content:"\nOpen the `shared/` folder in almost any React app that's been alive for a year. Count the files. Then try to explain, out loud, what the folder is *for* â not what's in it, what it's *for*. You'll stall. There's a date formatter, sure, and a Button. But there's also `useUserPermissions`, a `formatPolicyNumber`, half a checkout state machine, a `legacyApiAdapter`, and three hooks whose names start with `use` and end in a shrug.\n\nThat folder didn't get that way because someone was careless. It got that way because `shared/` is the one folder in your app with no rule attached to it. Everything else has a reason to reject a file. `shared/` accepts everything. And a folder that accepts everything isn't a category â it's a drain.\n\nI already flagged this in the [code structure article](/blog/react-playbook-code-structure): `shared/` is the most abused folder in React architecture. That piece told you the rule. This one is about why the rule is so hard to keep, and how to dig out once you've already lost.\n\n## Why the drawer fills up\n\nThe junk drawer isn't a discipline problem. If it were, telling people \"be more careful about `shared/`\" would fix it. It never does. The rot is structural, and it comes from three forces that all push the same direction.\n\n**The naming trap.** You write a hook. Where does it go? If it has an obvious owner â `features/edit-policy` â the answer is easy. But a lot of code sits in the awkward middle: not clearly owned, not clearly generic. `useDebouncedValue` is generic. `usePolicyDraftAutosave` is owned. But `useAutosave`, the one that *started* generic and quietly grew a `policyId` parameter â that one has no obvious home. So it goes to `shared/`, because `shared/` never says no.\n\n**The reuse reflex.** The moment a second file wants something, the instinct is to \"lift it up\" so both can reach it. This feels like good engineering. It's usually premature. Two components in the same feature using one helper is not a reason to move that helper three layers up into `shared/lib` â it's a reason to keep it *inside the feature*. Reuse across a boundary is a real signal. Reuse within a boundary is just normal code living near the thing that uses it.\n\n**The path of least resistance.** `shared/` is at the bottom of the dependency graph, so everything can import from it. That makes it the safest place to drop something when you're in a hurry and don't want to think about ownership. No import will ever break. And that's exactly the problem: the folder that's safe to import from is also the folder that's frictionless to dump into.\n\nPut those three together and you don't get a junk drawer because people are bad at their jobs. You get one because the folder is doing precisely what an unconstrained folder does under pressure. Delivery pressure always wins over categorization, and `shared/` is where \"I'll sort it later\" goes to never be sorted.\n\n> `shared/` is not a place you put things. It's a promise you make about them.\n\nThat reframing is the whole article. The promise is: *this code is domain-agnostic and portable â it would make sense in a completely different product.* Every file in `shared/` is you signing that promise. The drawer fills up because people put things there without noticing they're signing anything.\n\n## The test that actually works\n\nThe rule I gave in the structure article still holds, and it's the only test I've found that survives contact with a real team:\n\n> A file belongs in `shared/` only if you could move it to a *different product at the same company* and it would still make sense.\n\nA `Button`? Moves fine. A `formatCurrency`? Moves fine. An `http` client with your auth interceptor? Borderline â it moves if the auth scheme is company-wide, stays if it's app-specific. A `useUserPermissions` hook that knows your permission strings? Doesn't move. It's dressed up as generic â it takes a permission key, it returns a boolean, it *looks* reusable â but it's welded to your domain's idea of what a permission is. That's not shared code. That's domain code wearing a generic costume.\n\nHere's the tell, in code. This looks shared:\n\n```ts\n// shared/hooks/usePermission.ts\nexport function usePermission(key: string): boolean {\n const { permissions } = useCurrentUser();\n return permissions.includes(key);\n}\n```\n\nIt isn't. `useCurrentUser` is a domain concept. The moment this hook reaches sideways into `entities/user`, it stopped being portable â it now depends on *your* app's notion of a user. Move it to a different product and it breaks. It belongs in `entities/user`, exported as part of that entity's public surface. What's left in `shared/` should be the genuinely dumb primitive:\n\n```ts\n// shared/lib/includes.ts â portable, knows nothing\nexport const hasKey = (keys: string[], key: string) => keys.includes(key);\n```\n\nThe portability test isn't about whether code *can* be reused. Almost anything can be reused if you squint. It's about whether the code *knows about your domain*. Knowledge is the boundary, not usage.\n\n## Make the boundary a wall, not a suggestion\n\nA rule that lives only in a code review is a rule you will lose. Reviewers get tired. New people don't know it. The pressure that fills the drawer doesn't take weekends off. So the direction of dependencies â lower layers never importing from higher ones â has to be enforced by tooling, not vigilance.\n\nESLint's `no-restricted-imports` gets you most of the way
1with zero extra dependencies:\n\n```js\n// eslint.config.js\nexport default [\n {\n files: ['src/shared/**'],\n rules: {\n 'no-restricted-imports': [\n 'error',\n {\n patterns: [\n {\n group: [\n '@/features/*',\n '@/entities/*',\n '@/widgets/*',\n '@/pages/*',\n '@/app/*',\n ],\n message:\n 'shared/ must not import from higher layers. If this file needs domain code, it does not belong in shared/.',\n },\n ],\n },\n ],\n },\n },\n];\n```\n\nNow the `usePermission` example from earlier fails the build the instant it imports `useCurrentUser`. The error message isn't just \"no\" â it *explains the promise*: if this file needs domain code, it doesn't belong here. The lint rule becomes the reviewer who never gets tired and never forgets the rule. For richer needs â enforcing that features can't import each other, that entities stay isolated â a dedicated boundaries plugin does the same job with more expressive rules, but the built-in gets a small team surprisingly far.\n\nThe point isn't the exact config. It's that the promise stops depending on everyone remembering it. You made `shared/` say no. That single change is the difference between a folder that stays small and one that quietly triples every quarter.\n\n## Digging out of one you already have\n\nPrevention is easy to talk about when the drawer is empty. Most of the time it isn't. You've inherited forty files and a folder that's load-bearing in ways nobody documented. You can't stop shipping features to go on an architecture retreat, and a big-bang \"fix shared/\" pull request is a merge-conflict machine that reviewers will rubber-stamp out of exhaustion. So don't do that. Dig out incrementally.\n\n**First, audit without moving anything.** Go through `shared/` file by file and tag each one with a comment â three buckets only:\n\n- **Portable** â passes the test. Leave it. This is what `shared/` is *for*, and seeing what legitimately qualifies is calming.\n- **Owned** â knows about a domain concept. It has a real home in an `entity` or a `feature`; it's just sitting in the wrong place.\n- **Split** â genuinely two things fused together: a portable core with a domain-specific wrapper grown around it. The `useAutosave`-that-grew-a-`policyId` case.\n\n**Then move the easy ones.** The *Owned* bucket is pure relocation â cut the file, paste it into its real home, fix the imports, done. Your lint rule from the previous section will actually help here: as you move domain code out, anything still illegally reaching into higher layers lights up red, showing you the next thing to fix. The compiler turns into a to-do list.\n\n**Then split the hard ones.** For each *Split* file, separate the portable core from the domain wrapper. The core stays in `shared/` as a dumb primitive. The wrapper moves down to the feature that needs the domain-specific behavior:\n\n```ts\n// before â one file in shared/, quietly domain-aware\n// shared/hooks/useAutosave.ts\nexport function useAutosave(policyId: string, draft: PolicyDraft) { /* ... */ }\n\n// after â the portable half\n// shared/lib/useAutosave.ts â generic, no domain knowledge\nexport function useAutosave<T>(key: string, value: T, save: (v: T) => Promise<void>) { /* ... */ }\n\n// after â the domain half, where it belongs\n// features/edit-policy/model/usePolicyAutosave.ts\nexport function usePolicyAutosave(policyId: string, draft: PolicyDraft) {\n return useAutosave(`policy:${policyId}`, draft, savePolicyDraft);\n}\n```\n\nNotice the generic `useAutosave` got *better* by being forced portable â it now takes its save function as an argument instead of hard-coding `savePolicyDraft`, which makes it genuinely reusable for the first time. Enfor
1cing the boundary didn't just tidy the folder. It improved the primitive.\n\n**Then delete.** The satisfying part. Once the *Owned* and *Split* files are gone, you'll almost always find a handful of files nothing imports anymore â helpers that existed only to serve code that moved. Delete them. A shrinking `shared/` is the clearest signal the dig-out is working.\n\nYou don't have to finish in one sitting. Do a bucket a week. The direction matters more than the speed: as long as `shared/` is getting smaller and more portable over time instead of larger and vaguer, you're winning.\n\n## A word on the barrel\n\nWhile you're in here, you'll be tempted to add an `index.ts` that re-exports everything so imports look clean: `import { Button, formatCurrency } from '@/shared'`. Resist making that reflexive. A barrel over a genuinely small, stable, portable `shared/` is fine. A barrel over the junk drawer just gives the junk drawer a tidy front door â and it can quietly drag your whole `shared/` tree into every bundle that touches one export. The clean import is cosmetic; the coupling underneath is real. Fix the contents first. The public API of a folder is only worth having once the folder actually keeps a promise. That trade-off â barrels, bundles, and what a folder's public surface should really be â is a whole topic on its own, and one I want to come back to.\n\n## The folder was never the problem\n\nIt's easy to read all this as \"`shared/` is bad, avoid it.\" That's the wrong lesson. Every non-trivial app needs a home for genuinely portable code, and `shared/` is a perfectly good name for it. The folder was never the enemy.\n\nThe missing promise was. A junk drawer is just a drawer that nobody agreed on the contents of. The fix isn't a better folder name or a stricter reviewer â it's making the promise explicit, and then making a machine enforce it so the promise survives the next deadline. Do that, and `shared/` stops being the place your architecture goes to die and goes back to being the boring, dependable bottom of the stack. Boring is the goal. In `shared/`, boring is the whole point.\n\nNext, I want to pick up the barrel thread I brushed past earlier: [why the tidy `index.ts` that gives a folder a clean front door](/blog/react-playbook-barrel-files) can quietly drag half your app into a bundle that only needed one function â and when it's still worth it.\n\nIf you've got a `shared/` folder you're a little afraid to open â or a war story about one that got completely out of hand â I'd genuinely like to hear it. Reach out; I collect these.\n"},{id:"react-playbook-vr-ar",title:"React in 3D: When <div> Becomes <mesh>",excerpt:"The idea that React could render a 3D scene sounds like a stretch â React is for interfaces, 3D is a different universe. Except it isn't a stretch at all, and understanding why closes out this whole series with its central idea: React was never really about the DOM. This is where that finally becomes obvious.",tags:["React","3D","VR","AR","React Three Fiber","React Playbook"],date:"Jun 12, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-vr-ar/cover.png",content:"\nThe first time someone tells you that you can build a 3D scene â a rotating model, a lit environment, a VR world â using React, it sounds like a category error. React is for interfaces. Buttons, forms, lists. Three-dimensional graphics are a different universe with their own math, their own pipeline, their own everything. Surely gluing them together is a hack.\n\nIt isn't, and the reason it isn't is the perfect note to end this series on. Because after twenty-nine articles about data fetching and forms and auth and desktop and mobile, the thing rendering React in 3D quietly proves is the idea that was underneath all of it the whole time: **React was never really about the DOM.** React is a system for describing a UI as a function of state and letting a renderer figure out how to make it real. The DOM was just the first and most common target. Point that same system at a 3D scene graph and it works â not as a trick, but because 3D was always within the reach of what React actually is.\n\nSo this last article is short, and it's really about a realization more than a technology. Let me show you what I mean.\n\n## The Scene Graph Is Just Another Tree\n\nHere's the conceptual key. React renders trees. The DOM is a tree of elements â a `div` containing a `span` containing text. A 3D scene is *als
1o* a tree: a scene contains a mesh, a mesh has a geometry and a material, lights and cameras sit alongside them. Same fundamental shape â a nested hierarchy of things â which means React's component model, built for exactly that shape, fits a 3D scene as naturally as it fits a webpage.\n\n[React Three Fiber](https://r3f.docs.pmnd.rs) is the renderer that makes this literal. It's React â real React, same hooks, same state, same composition â where the elements are 3D objects instead of HTML tags. You don't write `<div>`; you write `<mesh>`. You don't nest `<p>` inside `<section>`; you nest a geometry and a material inside a mesh.\n\n```tsx\nimport { Canvas } from '@react-three/fiber';\n\nfunction Box() {\n return (\n <mesh rotation={[0.4, 0.2, 0]}>\n <boxGeometry args={[1, 1, 1]} />\n <meshStandardMaterial color=\"#22c55e\" />\n </mesh>\n );\n}\n\nexport function Scene() {\n return (\n <Canvas>\n <ambientLight />\n <pointLight position={[10, 10, 10]} />\n <Box />\n </Canvas>\n );\n}\n```\n\nRead that and the uncanny thing is how *ordinary* it is. It's components. `<Box />` is a component you could reuse, wrap in a list, conditionally render. `rotation` and `color` are just props â drive them from state and the cube animates or recolors, because it's the same reactivity you've used all series. `<mesh>` is to a 3D renderer what `<div>` is to the DOM renderer: a primitive the renderer knows how to draw. Everything you know about composing React components â props down, state-driven rendering, reuse, hooks â applies unchanged. Only the primitives at the bottom of the tree are different.\n\n## VR and AR Are Just More of the Same Realization\n\nOnce you accept that React can describe a 3D scene, VR and AR stop being a separate leap. A VR headset is, from the code's point of view, a 3D scene rendered in stereo with head tracking. AR is a 3D scene
1composited over a camera feed. The *scene* â the tree of meshes and lights and cameras â is the same tree React Three Fiber already renders; the WebXR layer adds the headset and the tracking on top.\n\nSo the progression is one idea extended, not three separate technologies to learn. Describe a 3D scene as a React tree. Render that tree to a screen â that's 3D on the web. Render it to a headset in stereo â that's VR. Composite it onto the real world â that's AR. At every step the component model is identical; what changes is the output device, exactly the way it changed when we went from browser tab to [desktop window](/blog/react-playbook-desktop) to [native phone](/blog/react-playbook-react-native). Same React, different target. You are not learning 3D-React and then VR-React and then AR-React. You are learning that React describes a tree, and the tree can be pointed at increasingly immersive places.\n\n## The Thing This Whole Series Was Actually About\n\nI saved this topic for last on purpose, because it makes the through-line of everything before it visible.\n\nLook back at what we did. We fetched data and let a renderer reconcile the result. We managed state and let components re-render as a function of it. We moved React onto the desktop, onto phones, and now into three-dimensional space â and at every single stop, the *same* mental model carried: describe what the UI should be for a given state, and let a renderer make it real on whatever the target happens to be. The target was a browser, then a native window, then a native view, then a scene graph. The model never changed. That's not a coincidence you stumble on at article thirty â it's the thing the whole series was quietly teaching under the cover of specific problems.\n\n> React is not a DOM library. It is a way of describing UI as a function of state, with a renderer that reconciles your description against some target. The DOM was always just the first target.\n\nThat's why a React developer can move across this entire landscape â web, desktop, mobile, 3D, immersive â without starting over each time. The specifics are real and each one takes genuine learning, as every article in this series insisted. But the foundation is portable in a way that's rare in software, and 3D is the clearest proof: the moment `<div>` becomes `<mesh>` and *nothing else about how you think has to change*, you understand what you actually learned. You didn't learn the DOM. You learned React.\n\n## How I'd Approach It â and Where the Series Lands\n\nStrip it to decisions:\n\n- **3D isn't a hack on top of React.** A scene is a tree, React renders trees, and React Three Fiber makes the elements 3D objects. `<mesh>` is the new `<div>`.\n- **Your whole component model transfers.** Props, state, hooks, c
1omposition, reuse â all unchanged. Only the leaf primitives differ.\n- **VR and AR are the same tree, a different output device.** Screen, headset, camera-composited world. Learn the scene, not three separate stacks.\n- **The portable asset was never the platform.** It's the model: UI as a function of state, reconciled by a renderer against a target.\n\nAnd that's where I'll leave the React Playbook. Thirty articles, from spinning up a project to rendering a cube in VR, and the honest summary of all of them is smaller than the page count suggests: pick tools that own the messy edges, keep your source of truth in one place, treat the client as untrusted and the boundary as sacred, and describe your UI as a function of state so it can follow you to whatever comes next. The specific libraries will churn â some of the ones I recommended will be replaced, and that's fine. The way of thinking is the part that lasts, and if this series left you with that instead of a list of dependencies, it did its job.\n\nThanks for reading this far. If the Playbook changed how you approach even one of these problems â or if you disagree with where I landed on some of them, which is entirely fair â I'd genuinely like to hear about it. That's the conversation that makes writing thirty of these worth it.\n"},{id:"react-playbook-react-native",title:"React Native: What 'Learn Once, Write Anywhere' Really Means",excerpt:"React Native's promise gets misremembered as 'write once, run everywhere' â and that misremembering is why so many web developers hit a wall. The knowledge transfers; the code and the instincts often don't. Here's the honest line between what your React experience buys you on mobile and what it quietly doesn't.",tags:["React","React Native","Mobile","Expo","React Playbook"],date:"Jun 09, 2026",readTime:"12 min read",coverImage:"/blog/react-playbook-react-native/cover.png",content:"\nThe single most useful thing to get straight about React Native is a slogan, and it's one people consistently misquote. The promise was never \"write once, run anywhere.\" It was \"*learn* once, write anywhere\" â and the difference between those two words is the entire reason web developers either love React Native or feel betrayed by it.\n\nBecause here's what actually transfers to mobile: your React knowledge. Components, props, state, hooks, the mental model of describing UI as a function of data â all of it, intact, on day one. You are not starting over. What does *not* transfer is a lot of your code and, more subtly, a lot of your instincts. The `<div>` you've typed a hundred thousand times doesn't exist. CSS as you know it doesn't exist. The layout habits your fingers have memorized quietly stop working. So you arrive on mobile fluent in the language and unfamiliar with the streets, and whether that feels empowering or frustrating comes down entirely to expecting it.\n\nThis article is about drawing that line honestly â what your React experience genuinely buys you on mobile, and where it stops â so you show up expecting \"learn once,\" not \"write once,\" and don't waste a week being angry at the wall.\n\n## There Is No DOM. That's the Whole Shift.\n\nStart with the concept that reorganizes everything else: React Native does not render to a browser, because there is no browser. Your components render to *actual native platform views* â a real iOS `UIView`, a real Android widget. React is the engine describing the UI; the thing it draws is native, not HTML.\n\nThis is why the primitives change. You don't write `<div>` and `<span>` and `<p>`, because those are HTML and there's no HTML here. You write `<View>` and `<Text>`, which map to genuine native components:\n\n```tsx\nimport { View, Text, Pressable } from 'react-native';\n\nexport function Greeting({ name }: { name: string }) {\n return (\n <View style={styles.card}>\n <Text style={styles.title}>Hi {name}</Text>\n <Pressable onPress={() => console.log('tapped')}>\n <Text>Tap me</Text>\n </Pressable>\n </View>\n );\n}\n```\n\nNotice how *familiar* the shape is â it's components, props, a style prop, an event handler. And notice how many small things are different: `View` not `div`, `Text` wrapping every piece of text (you can't just drop a bare string like in HTML), `onPress` not `onClick`, `Pressable` instead of a `button`. That's \"learn once, write anywhere\" in miniature â the *shape* of your React knowledge fits perfectly, and every *specific* has to be relearned. The engine is the same; the vocabulary is new.\n\n## Styling: It Looks Like CSS and Isn't\n\nThe place this bites hardest, and the one worth dwelling on, is styling â because it looks close enough to CSS that you trust it, then it violates that trust.\n\nReact Native styling *resembles* CSS: you write style objects with camelCased properties, and many familiar names work. But it is not CSS, and the differences are exactly the ones your instincts will trip over. There are no cascading stylesheets, no selectors, no inheritance in the way you rely on â styles are explicit, per-component objects. There's no `display: block` versus `inl
1ine`. Most jarring of all: **everything is flexbox, and it's vertical by default.** On the web, `flexDirection` defaults to `row`; in React Native it defaults to `column`, because a phone screen is tall. Your web layout muscle memory, built on horizontal-by-default flow, is subtly wrong on every screen until you internalize the flip.\n\n```tsx\nimport { StyleSheet } from 'react-native';\n\nconst styles = StyleSheet.create({\n card: {\n // flexDirection defaults to 'column' here â the opposite of the web.\n padding: 16,\n backgroundColor: '#fff',\n },\n title: { fontSize: 18, fontWeight: '600' },\n});\n```\n\nNone of this is hard. It's just *different*, and the danger is precisely that it looks similar enough that you don't respect the difference and spend an afternoon confused why your row is a column. Expect styling to be \"CSS-flavored, not CSS,\" and the friction drops away. Treat it as the CSS you know and it will quietly punish you.\n\n## Just Use Expo\n\nA practical fork, stated plainly because it saves the most pain: when you start a React Native project, use [Expo](https://expo.dev). Bare React Native drops you into native build toolchains â Xcode, Android Studio, native dependency management â which is a heavy, platform-specific world that has nothing to do with React and everything to do with mobile plumbing you didn't sign up to learn on day one.\n\nExpo is to React Native roughly what [a good meta-framework is to React on the web](/blog/react-playbook-starting-new-project): it owns the miserable setup and configuration so you can stay in the part you're good at. It handles the build tooling, gives you a clean managed workflow, and bundles access to native device APIs â camera, location, notifications â behind simple JavaScript, so reaching for the camera doesn't mean touching native code. It's the same lesson this whole series keeps landing on: don't hand-assemble the plumbing when a well-made tool already owns it. For the overwhelming majority of apps, Expo is simply the right starting point, and starting bare is a choice you should have a specific reason for.\n\n## The Instinct That Actually Needs Retraining\n\nBeyond the vocabulary, there's a deeper adjustment, and it's the one that separates a web app running on a phone from an app that belongs there. **Mobile is not a small desktop, and users can feel the difference instantly.**\n\nTouch is not a mouse. There's no hover â a whole category of web interaction just doesn't exist, and designs that lean on it fall flat. Targets have to be finger-sized, not cursor-precise. Gestures â swipe, long-press, pull-to-refresh â are expected vocabulary, not enhancements. Platform conventions differ, and iOS and Android users each have expectations about how navigation and controls should feel that a lowest-common-denominator layout ignores at its peril. And the device itself imposes realities the desktop rarely forces you to think about: constrained screens, variable network, battery, memory. The web developer's instinct to fill horizontal space, to treat the network as reliable, to assume hover and precise pointing â those instincts need retraining, and the app quality lives in that retraining far more than in the syntax.\n\nThis is the honest edge of \"learn once, write anywhere\": your React *transfers*, but your sense of what a good interface *is* has to expand from the web's assumptions to the phone's. That's not a knock on React Native â it's the reality of the platform being genuinely native, which is the whole point of using it.\n\n## How I'd Approach It\n\nStrip it to decisions:\n\n- **It's \"learn once,\" not \"write once.\"** Your React knowledge transfers whole; a lot of your code and instincts don't. Expect that and the wall becomes a curriculum.\n- **There is no DOM.** `View` and `Text`, not `div` and `span`; `onPress`, not `onClick`. You render to real native views.\n- **Styling is CSS-flavored, not CSS.** No cascade, no selectors, flexbox everywhere, and column-by-default. Respect the difference instead of trusting the resemblance.\n- **Use Expo.** It owns the native build plumbing so you stay in React. Go bare only with a specific reason.\n- **Retrain your interface instincts.** Touch, gestures, finger targets, platform conventions, device limits â mobile is native, not a narrow desktop.\n\nThe reason React Native either delights or frustrates comes down to which promise you showed up believing. Arrive expecting \"write once, run everywhere\" and every difference feels like a broken promise. Arrive expecting \"learn once, write anywhere\" and the exact same differences feel like a reasonable tax on real native rendering â you keep the expensive thing, your React fluency, and pay for the platform specifics, which is a genuinely good trade. The knowledge is the asset. The code was always going to be somewhat local to where it runs.\n\nFor the final article, the series leaves flat screens behind completely: [React in VR and AR](/blog/react-playbook-vr-ar) â where the same component model gets pointed at three-dimensional space, and what it means to write `<mesh>` instead of `<div>`.\n\nIf you're a web developer eyeing your first React Native project and bracing for a total restart, don't â it's far more transfer than restart, once you know
1which parts are which. Tell me what your app needs to do on the phone and I'll tell you where your web instincts will help and where they'll trip you.\n"},{id:"react-playbook-desktop",title:"React on the Desktop: When a Window Beats a Tab",excerpt:"The pitch is irresistible â ship your React app as a real desktop program, same code, native icon in the dock. Electron and Tauri both promise it. But the interesting question isn't 'can I,' it's 'what do I actually gain that a browser tab can't give me,' and the answer decides which tool, and whether you should bother at all.",tags:["React","Desktop","Electron","Tauri","React Playbook"],date:"Jun 05, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-desktop/cover.png",content:"\nThere's a specific moment where the desktop pitch becomes irresistible. You've built a genuinely good React app, it lives in a browser tab, and someone asks \"can we make it a real app?\" â with a dock icon, a name, a window of its own. And the answer is yes, easily, with the same React code you already have. Electron and Tauri will both wrap it into a program that installs like any native app. The demo takes an afternoon and feels like magic.\n\nSo the tempting question is \"how do I turn my React app into a desktop app,\" and it's the wrong one to lead with. The right question â the one that actually determines whether this is worth doing and which tool to reach for â is *what do I gain by leaving the browser at all?* Because a desktop wrapper that just shows your website in a frameless window is a worse browser tab: heavier, harder to update, and offering the user nothing they didn't already have. The whole justification for going desktop is the stuff a browser tab structurally *cannot* do, and if you don't need that stuff, you probably shouldn't be here.\n\nThis article is about that line â what the desktop actually buys you â and the honest tradeoff between the two tools that get you there.\n\n## What a Browser Tab Genuinely Can't Do\n\nStart with the reason to leave the browser, because it's the whole point. A web page runs in a sandbox, deliberately walled off from the machine it's on â that's a security feature, not a limitation to complain about. But it means there are real capabilities a tab simply doesn't have, and those capabilities are your entire reason for going desktop.\n\nDeep filesystem access â reading and writing arbitrary files on the user's disk, not the sandboxed trickle a browser allows. Real OS integration â a tray icon, native menus, global keyboard shortcuts that work when your app isn't focused, notifications that feel like the system's own. Talking to native or system-level resources the browser keeps off-limits. Working genuinely offline as a first-class program, not a web page hoping its service worker cached the right things. If your app needs one of these, the desktop isn't a nicety â it's the only place the feature can exist.\n\nAnd the contrapositive matters just as much: if your app needs *none* of these â if it's fundamentally a web app that someone just wants to \"feel\" installed â then wrapping it in Electron mostly buys you weight and update headaches. Be honest about which side of that line you're on before you install anything. The desktop is for apps that need to touch the machine, not for apps that want a fancier bookmark.\n\n## The Two Tools, and the Real Tradeoff\n\nAssuming you genuinely need out of the browser, there are two serious choices, and they represent a real architectural fork â not a matter of taste.\n\n**Electron** is the established one â VS Code, Slack, Discord are all Electron. Its model is straightforward: it ships an entire copy of Chromium *and* Node.js inside your app. Your React runs in a real Chrome, and you get full Node in the backend process, so everything you know works exactly as expected. The cost is equally straightforward: because every app bundles its own whole browser, a trivial Electron app starts at a hundred-plus megabytes and a chunk of memory before it does anything. You're shipping a browser per app, times every Electron app the user has installed.\n\n**Tauri** is the modern challenger with a different bet. Instead of bundling Chromium, it uses the operating system's *own* built-in webview â the browser engine already on the machine. And instead of Node, its backend is Rust. The payoff is dramatic: Tauri apps are a fraction of the size and memory, often a few megabytes instead of a hundred. The cost is the two edges of that same bet. Using the OS webview means you're rendering on WebKit on macOS, WebView2 on Windows â *different engines* â so you inherit cross-browser inconsistency as a desktop concern, which is a genuinely strange thing to have to test for in an installed app. And the native backend is Rust, so any serious system-level logic pulls
1you into a language your React team may not know.\n\n```\n Electron Tauri\n Renderer bundled Chromium (same OS webview (WebKit / WebView2,\n everywhere) differs per platform)\n Backend Node.js Rust\n App size 100MB+ (ships a browser) a few MB (uses the OS's)\n You trade size & memory cross-engine testing + Rust\n```\n\nSo the choice isn't \"which is better,\" it's which cost you'd rather pay. Electron trades disk and memory for total consistency and a stack your web team already knows. Tauri trades a small footprint for cross-engine testing and a Rust backend. Neither is wrong; they're priced differently, and the right pick depends on whether size or familiarity hurts you more.\n\n## The Part That's Actually Different: Two Processes and a Bridge\n\nWhichever you choose, there's one architectural shift that catches web developers off guard, and it's worth understanding because it shapes everything you build. A desktop app is not one environment â it's *two*, with a guarded door between them.\n\nYour React UI runs in the renderer, which is still fundamentally a web context, sandboxed. The powerful stuff â filesystem, OS APIs, native calls â runs in a separate, privileged backend process (Node in Electron, Rust in Tauri). They can't just call each other's functions; they communicate over an explicit bridge, passing messages across. Your React code doesn't read a file directly â it *asks* the backend to, and the backend, which actually has the permission, does it and sends the result back.\n\n```ts\n// Renderer (your React UI): it cannot touch the disk itself.\n// It asks the privileged backend to do it â Tauri example.\nimport { invoke } from '@tauri-apps/api/tauri';\n\nasync function loadNote(id: string) {\n // Crosses the bridge to the Rust backend, which has real file access.\n const contents = await invoke<string>('read_note', { id });\n return contents;\n}\n```\n\nThis should feel familiar, because it's the same pattern the whole series keeps circling: a sandboxed, untrusted frontend asking a privileged, trusted backend to do the dangerous thing â exactly like the [client/server split in auth](/blog/react-playbook-authentication), just moved onto one machine. The renderer is the browser; the backend process is your server; the bridge is the API between them. Treat that boundary with the same seriousness â validate what crosses it, expose only the specific operations you mean to â and a desktop app stops feeling exotic and starts feeling like a client/server app you happen to ship in one installer.\n\n## How I'd Approach It\n\nStrip it to decisions:\n\n- **Ask what you gain, not whether you can.** If you don't need filesystem, OS integration, or true offline, the desktop is a heavier browser tab. Don't ship one for a fancy bookmark.\n- **The desktop is for touching the machine.** Deep file access, tray/menus/global shortcuts, native resources, first-class offline â that's the whole reason to leave the browser.\n- **Electron vs Tauri is a cost choice, not a quality one.** Electron: big and memory-hungry, but consistent and web-stack-familiar. Tauri: tiny, but cross-engine testing and a Rust backend.\n- **It's two processes with a bridge.** Your React renderer asks a privileged backend to do the powerful things. Treat that bridge like a client/server boundary, because it is one.\n\nThe thing I want you to take from this is that \"turn my React app into a desktop app\" is the easy, uninteresting half. The wrapper is a solved afternoon. The real work â and the only justification for the extra weight and the two-process complexity â is the native power you reach for once you're out of the sandbox. Go desktop when your app needs the machine. Stay in the tab when it doesn't. And when you do go, remember you didn't escape the client/server split, you just packaged both halves into one window.\n\nThe next article stays off the web but changes machines entirely: [React Native and mobile](/blog/react-playbook-react-native) â where \"same code, new platform\" gets far more honest about what actually carries over and what doesn't.\n\nIf you're weighing a desktop build and can't tell whether you truly need to leave the browser, that's the exact question worth answering first â and it's usually clearer than it feels. Tell me what native capability you're reaching for and I'll tell you if the desktop is really the only place to get it.\n"},{id:"react-playbook-component-documentation",title:"Documenting Components: The Only Docs That Don't Rot",excerpt:"Every team writes component docs the same way â a wiki page, a screenshot, a table of props â and every team watches them go stale within a month. The problem isn't discipline. It's that the docs live in a different place than the code. The fix is docs that can't drift because they're generated from the component itself.",tags:["React","Documentation","Storybook","React Playbook"],date:"Jun 02, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-component-documentation/cover.png",content:"\nEvery team I've watched try to document their components starts in the same place: a page in the wiki. Someone writes up the `Button` â here are the variants, here's a screenshot, here's a table of props â and for about three weeks it's great. Then someone adds a `loading` prop and doesn't update the page. Then the primary color changes and the screenshot is now a lie. A month in, the docs describe a component that no longer exists, and the whole team has quietly learned to not trust them. So they read the source instead, and the docs rot in place, occasionally misleading a new hire.\n\nThis isn't a discipline problem, and treating it as one â \"we just need to be better about updating the docs\" â is why it keeps happening. People aren't lazy; the docs are structurally doomed. The reason is simple and it's the whole point of this article: **the documentation lives in a different place than the code it describes.** Two sources of truth, updated by two separate acts of will, and one of them always falls behind. You've seen this exact shape before â it's the same tw
1o-sources-of-truth failure as the [real-time cache](/blog/react-playbook-websockets), just applied to prose instead of state.\n\nSo the question isn't \"how do we write better component docs.\" It's \"how do we make docs that *can't* drift\" â and the answer is to stop writing docs that sit next to the code and start generating them from the code itself.\n\n## Why the Wiki Always Loses\n\nSit with why the wiki page decays, because the mechanism matters. When you change a component, updating its separate documentation is a *second, optional action*. The code change ships value on its own; the doc update is extra work with no immediate payoff, easy to defer, easy to forget, and nothing breaks when you skip it. So under any deadline â which is always â the doc update is the first thing to go. Multiply that across a team and a year, and every hand-maintained doc trends toward wrong.\n\nThe insight that fixes it is to remove the second action entirely. If the documentation is *derived from the code* rather than maintained alongside it, then changing the code changes the docs, automatically, with no act of will required. The drift becomes impossible not because everyone got more disciplined, but because there's no longer a gap for drift to live in. This is the same instinct as generating types from a [schema](/blog/react-playbook-typescript-patterns) or [migrations from a model](/blog/react-playbook-database-turso): one source of truth, everything else derived. Documentation is just another thing that should be derived, not duplicated.\n\n## The Props Table Should Come From the Props\n\nThe most concrete version of this: that table of props everyone hand-writes and forgets to update? It should not be hand-written. Your component's props are *already* fully described â in its TypeScript types. That's a machine-readable specification of every prop, its type, whether it's required, sitting right there in the code.\n\n```tsx\ninterface ButtonProps {\n /** Visual style of the button. */\n variant: 'primary' | 'secondary' | 'danger';\n /** Shows a spinner and disables interaction. */\n loading?: boolean;\n /** Called when the button is clicked. */\n onClick: () => void;\n}\n\nexport function Button({ variant, loading, onClick }
1: ButtonProps) {\n /* ... */\n}\n```\n\nEverything a props table would contain is in that interface â the names, the exact allowed values of `variant`, which props are optional, and even the descriptions in the doc comments. Tooling like [Storybook](https://storybook.js.org) reads those types directly and generates the props table for you. Add a prop, the table gains a row. Change `variant`'s options, the table updates. Delete a prop, its row disappears. You didn't maintain anything â the documentation is a *view* of the types, and the types can't be wrong because they're the code that actually runs. The single most rot-prone piece of component docs becomes the single most reliable one, purely by deriving it instead of writing it.\n\n## Living Examples Beat Screenshots\n\nThe other half of component docs is showing the thing in use, and here the screenshot is the villain. A screenshot is a photograph of the component at one moment in the past â it stops being accurate the instant anything changes, and it can't show a single interactive state. Hover, focus, loading, disabled, the error variant â a static image shows none of it and silently goes stale on all of it.\n\nThe fix is the same move again: don't take a picture of the component, *render the real component*. A story in Storybook is a live instance of the actual component, mounted with real props, that you can interact with:\n\n```tsx\nimport type { Meta, StoryObj } from '@storybook/react';\nimport { Button } from './Button';\n\nconst meta: Meta<typeof Button> = { component: Button };\nexport default meta;\n\nexport const Primary: StoryObj<typeof Button> = {\n args: { variant: 'primary', onClick: () => {} },\n};\n\nexport const Loading: StoryObj<typeof Button> = {\n args: { variant: 'primary', loading: true, onClick: () => {} },\n};\n```\n\nThose aren't images of a button â they're the button, running, with the exact props your app would pass. If the button changes, the stories change with it, because they *are* the component, not a snapshot of it. You can hover the real hover state, watch the real spinner, tab to the real focus ring. A screenshot documents what the component looked like once; a story documents what the component *is* right now, and stays honest by construction.\n\nAnd there's a compounding benefit that ties back to the [design conversation](/blog/react-playbook-design-prototyping): a living component catalog is where designers and engineers actually agree on what exists. When the real `Button`, in all its states, is browsable in one place, \"does this already exist?\" has an answer you can see and click â which is the difference between reusing the component and rebuilding it slightly differently for the fifth time.\n\n## How I'd Approach It\n\nStrip it to decisions:\n\n- **Separate docs always rot.** It's not a discipline problem â it's two sources of truth. Stop maintaining docs next to the code.\n- **Derive, don't duplicate.** Generate documentation from the code so changing the code changes the docs, automatically.\n- **The props table comes from the types.** Your TypeScript interface already is the spec; let tooling render it. Never hand-write a props table.\n- **Render real components, not screenshots.** A live story stays accurate and shows interactive states; an image is stale the moment you save it.\n- **A living catalog drives reuse.** When every component is browsable in all its states, \"does this exist?\" becomes answerable â and things get reused instead of rebuilt.\n\nThe reason component docs have such a bad reputation is that almost everyone builds them to fail, then blames themselves when they do. A wiki page and a screenshot are drift waiting to happen, and no amount of resolve fixes a structural flaw. The shift that makes documentation actually last is to stop treating it as a thing you *write* and start treating it as a thing you *derive* â from the types, from the real components, from the code that's already the truth. Do that, and your docs stop being a chore you fall behind on and become a side effect of building the components at all. That's the only kind of documentation I've seen survive contact with a deadline.\n\nThis is where the \"build a real app\" spine of the series ends â from starting a project all the way through documenting what you built. The remaining articles step outside the browser entirely: next up, [React on the desktop](/blog/react-playbook-desktop) â when Ele
1ctron or Tauri actually earns its place, and what changes when your React app is a window instead of a tab.\n\nIf your team has component docs that nobody trusts, it's almost certainly the separate-source-of-truth problem, and it's fixable without a documentation crusade. Tell me how your docs are set up and I'll tell you which piece to derive instead of write.\n"},{id:"react-playbook-design-prototyping",title:"Design Prototyping for React: Where the Architecture Actually Starts",excerpt:"Most engineers treat the Figma file as a picture to reproduce. That framing is why so many handoffs go badly. A good design isn't a picture â it's the first draft of your component architecture, and the decisions that decide whether your codebase stays sane are made before a single line of React is written.",tags:["React","Design","Architecture","React Playbook"],date:"May 29, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-design-prototyping/cover.png",content:"\nThe way most engineers relate to a design file is as a target to hit. The designer hands over a Figma frame, and the job â as everyone tacitly agrees â is to make the screen look like the picture. Pixel for pixel, color for color. Reproduce it, get it approved, move on.\n\nI want to push on that framing, because I think it's the root of a lot of avoidable pain. A design isn't a picture you copy. It's the first draft of your component architecture, whether anyone treats it that way or not. The structure of that screen â what repeats, what nests, what varies, where the boundaries fall â *is* the structure of the code you're about to write. Which means the most consequential architectural decisions in a feature are often made in a design tool, by someone thinking about spacing and hierarchy, long before an engineer opens their editor. And when the design's structure and the code's structure disagree, you get the codebases everyone recognizes: technically matching the mockup, quietly miserable to maintain.\n\nSo this article is about reading a design the way it deserves to be read â as architecture â and why that shift changes both what you build and how you work with the people who design it.\n\n## A Design Has Structure, Not Just Appearance\n\nLook at any real screen and there are two different things present at once. There's the *appearance* â the colors, the fonts, the shadows, the exact pixels. And there's the *structure* â this card repeats six times, that button is the same button as the one on the other screen, this panel contains a header and a list, these three stats are the same shape with different data.\n\nReproducing appearance is the shallow job. Reading structure is the real one, because structure is what maps to components. When you see six cards, the architectural fact isn't \"six cards\" â it's \"one `Card` component, rendered six times.\" When you see a button here that's clearly the same button as three other screens, the fact is \"this is one shared `Button`, not a thing I restyle locally each time.\" The design is quietly telling you what your components are, what your reusable primitives are, and how they compose. A developer who only sees the picture reproduces the pixels and invents an ad-hoc structure underneath. A developer who reads the structure lets the design hand them their [component boundaries](/blog/react-playbook-code-structure) for free.\n\n> A mockup shows you what the screen looks like. Read carefully, it also shows you what your components are, which ones repeat, and where they compose. The second reading is the one that keeps your codebase sane.\n\n## Same Component, Different States â the Thing Static Mockups Hide\n\nHere's where the \"design is a picture\" framing does the most damage, and it's the single most valuable thing to internalize about prototyping for React.\n\nA static mockup shows you one moment. But a React component is never one moment â it's every state that data can put it in. The screen you were handed shows a list with eight items. Your component also has to handle: zero items (the empty state), one item, hundreds (does it scroll, paginate, virtualize?), the loading moment before any data arrives, and the error case when the fetch fails. The mockup showed you one of five or six realities, and if you only build the one you were shown, you'll ship a component that looks perfect in the demo and breaks the first time real data is empty or slow.\n\nThis is why the most useful thing you can do with a design *isn't* to start coding it â it's to interrogate it. Before writing the component, I go looking for the states the mockup didn't show. What does this look like with no data? While it's loading? When it fails? When the text is three times longer than the placeholder? When there are two hundred rows instead of eight? Half the time the honest answer is \"the designer didn't think about that yet,\" and surfacing it *before* you build is enormously cheaper than discovering it in QA. The static frame is the happy path; your component is responsible for all the paths, and prototyping is where you find the missing ones.\n\n## This Is a Conversation, Not a Handoff\n\nThat interrogation is exactly why I don't think of design-to-code as a handoff. A handoff is one-directional: design finishes, throws the result over a wall, engineering catches it. But the questions that matter â what are the empty and error states, is this really the same component as that one, does this pattern already exist in our system â can only be answered *together*, and answering them early is where the leverage is.\n\nWhen engineering engages with design *during* prototyping instead of after, a few things change. Reusable pieces get spotted before they're built five slightly-different times. Missing states get designed instead of improvised by whichever developer hits them first. And the design starts to align with the components that already exist â which connects straight back to the [design systems](/blog/building-design-systems-that-scale) idea: a prototype built from the components you already have is a prototype that ships in a fraction of the time, because the answer to \"how do I build this\" is \"you mostly already did.\" The handoff model forecloses all of that by making the conversation happen too late, after the structural decisions have already hardened on both sides.\n\nI'm not arguing engineers should design. I'm arguing that the boundary between \"design work\" and \"architecture work\" is far blurrier than the org chart suggests, and pretending it's a clean line â a wall with a handoff over it â is how you get designs that fight the code and code that fights the design.\n\n## How I'd Approach It\n\nStrip it to decisions:\n\n- **Read a design for structure, not just appearance.** What repeats, what nests, what varies â that's your component tree, handed to you.\n- **A static mockup is one state.** Before coding, hunt for the ones it hides: empty, loading, error, overflow, huge data. Build all the realities, not the one you were shown.\n- **Interrogate before you implement.** The cheapest place to discover a missing state is a design review, not QA.\n- **Treat it as a conversation, not a handoff.** Engaging during prototyping surfaces reuse and missing states while they're still cheap to fix.\n- **Prototype from your existing components.** A design aligned to the system you already have ships far faster than one drawn from scratch.\n\nThe reframe that changed how I work is small but load-bearing: the design tool is where architecture starts, not where it waits. The person arranging cards and spacing is making decisions about repetition, hierarchy, and composition â the exact decisions I'll be encoding in components an hour later. When I treat the mockup as a picture to copy, I throw all that structural information away and rebuild it, worse, in code. When I treat it as the first draft of the architecture, the design does half the hard thinking for me â and the half it left out becomes a conversation instead of a bug.\n\nNext, the flip side of building components well: [documenting them](/blog/react-playbook-component-documentation) â so the reusable pieces you and your designers just agreed on actually get reused instead of quietly rebuilt for the fifth t
1ime.\n\nIf your team's design-to-code process feels like reproducing pictures and constantly rediscovering missing states in QA, that's the handoff model doing its usual damage. Tell me where it hurts â the reuse, the missing states, the drift â and I'll tell you where the conversation needs to move earlier.\n"},{id:"react-playbook-mails",title:"Sending Email from a React App: A Backend Problem in Disguise",excerpt:'"Just send them an email" is one of those sentences that sounds like a five-minute task and turns into a week. The React part is trivial. The hard part is that email is a deceptively deep backend system with deliverability, templates, and reputation â and none of it belongs in your frontend. Here\'s where the real work actually is.',tags:["React","Email","Backend","React Playbook"],date:"May 26, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-mails/cover.png",content:"\n\"When someone signs up, just send them a welcome email.\" It's the kind of request that gets tossed into a standup like it's nothing, and the person saying it genuinely believes it's a five-minute task. Send an email. How hard could it be?\n\nHere's the thing that makes email sneaky: the part you'd expect to be hard is trivial, and the part you'd expect to be trivial is where the whole thing lives. Triggering an email from your app is a single function call. *Actually getting that email into someone's inbox* â not their spam folder, not bounced, not silently dropped â is a genuinely deep backend problem involving deliverability, sender reputation, authentication protocols, and a rendering environment stuck two decades in the past. And almost none of it is a React problem, which is exactly why it catches frontend developers off guard.\n\nSo this article is a map of where the real work is. Because the most useful thing I can tell you about email in a React app is which parts to not do yourself, and why the boundary sits where it does.\n\n## The Line: The Client Never Sends Email\n\nStart with the hard boundary, because it's the one people are tempted to cross. **Your React app does not send email. Ever.** It triggers a request to your backend, and your backend sends the email.\n\nThis isn't a style preference, it's a security wall. Sending email requires credentials â API keys for your email service â and anything in your React bundle is public. Ship that key to the client and you've handed the entire internet the ability to send email *as you*, which means spammers now have a free megaphone wearing your domain's reputation. Within days your domain is blacklisted and even your legitimate mail stops arriving. So the flow is always the same shape as [payments](/blog/react-playbook-payments) and [auth](/blog/react-playbook-authentication): the client asks, the server acts, and the credential never leaves the server.\n\n```ts\n// The client's entire involvement: ask the backend to do it.\nasync function requestWelcomeEmail(userId: string) {\n await fetch('/api/emails/welcome', {\n method: 'POST',\n body: JSON.stringify({ userId }),\n });\n}\n```\n\nThat's the whole frontend story. There is no `sendEmail` in your components, no email API key in your environment variables with a public prefix, no SMTP in the browser. The moment email touches the client, you've done it wrong. Everything interesting from here happens on the server.\n\n## Why You Don't Talk to SMTP Yourself\n\nOn the backend, the naive mental model is that you connect to a mail server over SMTP and send. You *can* do that. You shouldn't, and understanding why is the core of this whole topic.\n\nGetting an email delivered is not about transmitting it â it's about being *trusted* enough that the receiving server puts it in the inbox instead of the spam folder or the trash. That trust is a system: your domain needs authentication records (SPF, DKIM, DMARC) that prove you're allowed to send as your domain; your sending IP needs a *reputation* built over time; you need to handle bounces and complaints so you stop mailing dead addresses; and you need to not trip the spam filters that treat unknown senders with suspicion. Running your own mail server means owning all of that â reputation, blacklist monitoring, authentication, the works â and it is a full-time infrastru
1cture job that has nothing to do with your product.\n\nThis is why transactional email services â [Resend](https://resend.com), Postmark, SendGrid â exist, and why using one isn't laziness but the correct architecture. They've spent years building sender reputation, they handle the authentication protocols, they manage bounces and deliverability, and they give you a clean API. Your backend calls that API; the hard, invisible work of *actually getting delivered* is theirs.\n\n```ts\n// Your server. The service owns deliverability; you own intent.\nimport { Resend } from 'resend';\nconst resend = new Resend(process.env.RESEND_API_KEY); // server-only secret\n\napp.post('/api/emails/welcome', async (req, res) => {\n const user = await getUser(req.body.userId);\n await resend.emails.send({\n from: '[email protected]',\n to: user.email,\n subject: 'Welcome aboard',\n react: <WelcomeEmail name={user.name} />, // more on this below\n });\n res.json({ queued: true });\n});\n```\n\nSame lesson as every article in this series: the raw capability (SMTP) is a footgun, and the value is a service that owns the messy, reputation-dependent edges. You are buying deliverability, not a send button.\n\n## The Genuinely Weird Part: Email Rendering Is Stuck in 2005\n\nHere's the one place your React skills partly transfer, and it comes with a nasty surprise. You'd assume you can build an email template with normal components and modern CSS. You cannot, and this trips up every frontend developer exactly once.\n\nEmail clients â Outlook especially, but not only â render HTML with engines that are decades behind browsers. No flexbox, no grid, no modern CSS, inconsistent support for things you consider baseline. The reliable way to lay out an email is *tables*, the way the web worked in 2005, with inlined styles because `<style>` blocks get stripped. Hand-writing that is miserable and easy to get wrong across the dozens of clients your users actually use.\n\nThe modern rescue is that you *can* write email templates as React components â using a library like [React Email](https://react.email) â and it compiles them down to the table-based, inline-styled HTML that survives Outlook. You get to think in components; the library deals with the 2005 rendering reality underneath.\n\n```tsx\nimport { Html, Button, Text } from '@react-email/components';\n\nexport function WelcomeEmail({ name }: { name: string }) {\n return (\n <Html>\n <Text>Hi {name}, glad you're here.</Text>\n <Button href=\"https://yourdomain.com/start\">Get started</Button>\n </Html>\n );\n}\n```\n\nThat looks like a normal component, but `<Button>` and `<Text>` aren't rendering `<button>` and `<p>` â they emit the gnarly, table-wrapped, inline-styled markup that renders consistently from Gmail to a decade-old Outlook. It's the same pattern once more: a library absorbing an ugly reality so you can work in a sane abstraction. You write React; it ships something that would make you cry if you had to write it by hand.\n\n## How I'd Approach It\n\nStrip it to decisions:\n\n- **The client never sends email.** It asks the backend. The API key is a server-only secret â leaking it burns your domain's reputation.\n- **Don't run your own mail server.** Deliverability is a reputation-and-authentication system, not a transmission problem. Use a transactional email service.\n- **You're buying deliverability, not a send button.** SPF/DKIM/DMARC, bounce handling, and IP reputation are the actual product these services sell.\n- **Email rendering is stuck in the past.** Write templates as React components via React Email; let it compile down to the table-based HTML that survives Outlook.\n\nThe reason \"just send an email\" turns into a week is that the sentence hides the entire iceberg. The tip â trigger a send â is the trivial thing everyone pictures. The mass underneath is deliverability, authentication, reputation, and a rendering environment that predates most of the developers working on it. The good news is that the whole iceberg is a solved, buyable problem: a transactional service for delivery, React Email for templates, and a hard rule that none of it touches the client. Get the boundary right and email goes back to being the five-minute task everyone assumed it was â because someone else already did the hard 95%.\n\nNext, the series shifts from wiring things up to thinking about them differently: [design prototyping for React](/blog/react-playbook-design-prototyping) â where design work stops being visual and starts being architectural, and why that handoff matters more than people admit.\n\nIf you've got emails landing in spam, or a template that looks perfect everywhere except Outlook, those are the two classic email potholes â deliverability and rendering â and both have known fixes. Tell me which one's biting you and I'll point you at the layer to check.\n"},{id:"react-playbook-file-upload",title:"File Upload in React: The Input Is Easy, the File Is Not",excerpt:"An upload looks like a one-liner â a file input, a POST, done. Then someone uploads a two-gigabyte video on hotel wifi and the whole illusion collapses. The hard part was never the input. It's moving a large, unreliable blob of bytes from a browser to somewhere it can live. Here's how I actually handle it.",tags:["React","File Upload","Storage","React Playbook"],date:"May 22, 2026",readTime:"12 min read",coverImage:"/blog/react-playbook-file-upload/cover.png",content:"\nAdding a file upload feels like a solved problem. You drop an `<input type=\"file\">` on the page, grab the file in an `onChange`, POST it to an endpoint. Ten minutes of work, and on your machine â with a 40KB avatar and localhost â it works flawlessly. You move on.\n\nThen it meets reality. Someone uploads a two-gigabyte video from hotel wifi. Someone's connection drops at 90%. Someone picks a 500MB file and
1your server, which happily buffered the 40KB avatar into memory, tries to do the same with half a gigabyte and falls over. The file input was never the hard part. The hard part is that a file is a *large, unreliable stream of bytes* that has to travel from an untrusted browser, across a flaky network, to somewhere it can actually live â and every one of those words is a problem.\n\nSo this article isn't about the input element. It's about the thing underneath: moving real files, at real sizes, over real networks, without melting your server or lying to your user about what's happening.\n\n## The First Decision: Your Server Shouldn't Touch the File\n\nHere's the instinct that causes most upload pain: the file goes browser â your server â storage. Your React app POSTs to your API, your API receives the bytes and forwards them to S3 or wherever. It's the obvious shape, and it's the wrong one for anything but tiny files.\n\nWhy it's wrong: your server now has to *hold* the file â in memory or on disk â while it passes through. A handful of users uploading large files at once and your server is doing nothing but shuttling bytes, burning memory and connection time on work that isn't yours. You've turned your application server into a file relay, and file relays are exactly what dedicated storage services already are, better than you.\n\nThe pattern that fixes it is **presigned uploads**, and it's worth understanding because it reshapes the whole flow. The file goes browser â storage *directly*, and your server's only job is to hand out permission. The client asks your API \"I want to upload this file,\" your API asks the storage service for a short-lived, single-purpose URL, and hands that URL back. The browser then uploads straight to storage. Your bytes never touch your server.\n\n```ts\n// Your server: it never sees the file. It only grants permission.\napp.post('/api/uploads/sign', async (req, res) => {\n const { filename, contentType } = req.body;\n\n // Ask storage for a short-lived URL scoped to this one upload.\n const uploadUrl = await storage.createPresignedUrl({\n key: `uploads/${crypto.randomUUID()}/${filename}`,\n contentType,\n expiresIn: 60, // seconds â the URL is useless after that\n });\n\n res.json({ uploadUrl });\n});\n```\n\n```ts\n// The browser: uploads the bytes directly to storage, not to you.\nasync function uploadFile(file: File) {\n const { uploadUrl } = await fetch('/api/uploads/sign', {\n method: 'POST',\n body: JSON.stringify({ filename: file.name, contentType: file.type }),\n }).then((r) => r.json());\n\n await fetch(uploadUrl, { method: 'PUT', body: file }); // straight to storage\n}\n```\n\nLook at how the responsibilities split. Your server does a tiny, fast, cheap thing â validate the request, mint a scoped URL, respond. The multi-gigabyte transfer happens between the browser and a service built for exactly that. This is the same [server-is-the-authority, client-does-the-work](/blog/react-playbook-authentication) split from the auth and payments articles: your backend controls *permission*, but it doesn't do the heavy lifting it has no business doing.\n\n## The Second Reality: The Upload Is a Process, Not a Request\n\nOnce the file is going straight to storage, the next thing to accept is that a large upload is not an event that happens â it's a process that *unfolds over time*, and your UI has to treat it that way.\n\nA 40KB avatar uploads instantly, so you can pretend it's a normal request. A 500MB file takes a minute or more, and during that minute the user is staring at your interface wondering if it's working. If you show them a frozen spinner, they'll assume it hung and refresh â killing the upload. So progress isn't a nice-to-have here; it's the difference between a feature that works and one users abort halfway. You have to report how far along the bytes are:\n\n```ts\nfunction uploadWithProgress(url: string, file: File, onProgress: (pct: number) => void) {\n const xhr = new XMLHttpRequest();\n xhr.upload.addEventListener('progress', (e) => {\n if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));\n });\n xhr.open('PUT', url);\n xhr.send(file);\n}\n```\n\nThat `xhr.upload.progress` event â one of the few times raw `XMLHttpRequest` still earns its place over `fetch`, which can't report upload progress â is what lets you draw a real progress bar. And a real progress bar changes user behavior: people wait for a bar that moves, and they abandon a spinner that doesn't. The technical work of measuring progress is small; the UX consequence of showing it is large.\n\n## The Third Reality: Big Files Need to Survive a Dropped Connection\n\nNow the hardest layer, and the one that separates a toy from a product: what happens at 90% when the wifi blinks?\n\nWith a single PUT of the whole file, the answer is brutal â the upload fails and starts over from zero. For a 40KB file, who cares. For a 2GB file on a shaky connection, \"start over from zero\" may mean it *never* completes, because the odds of an uninterrupted multi-gigabyte transfer on bad wifi are genuinely low. The single-request model has a size ceiling baked in, and above it, uploads just don't finish.\n\nThe answer is **chunked (multipart) uploads**: split the file into pieces, upload each piece independently, and reassemble them in storage. Now a dropped connection costs you one chunk, not the whole file â you retry that chunk and continue. Uploads become resumable, and the size ceiling effectively disappears.\n\nI want to be honest about the tradeoff, because this is where the \"it's just an input\" fantasy fully die
1s: chunking is genuinely more complex. You're tracking which chunks succeeded, retrying the ones that didn't, handling out-of-order completion, and telling storage to stitch them back together. This is real coordination logic, and it's exactly why you don't hand-roll it. Storage services expose multipart upload APIs, and libraries like [Uppy](https://uppy.io) wrap the whole thing â chunking, retries, progress, resumability â behind a component. The same lesson as every article in this series: the raw capability is a footgun, and the value is a library that owns the messy edges so you spend your attention on your product, not on retry bookkeeping.\n\n## Don't Forget: The File Is Untrusted\n\nOne security note that's too important to skip, because uploads are a classic attack surface. A file arriving from a browser is *user input*, and the same rule from the [payments](/blog/react-playbook-payments) and [auth](/blog/react-playbook-authentication) articles applies: never trust it.\n\nThe client can claim a file is a 2MB image and actually send a 2GB executable. It can lie about the content type. So validation belongs on the boundary you control â size limits, allowed types, and ideally scanning â enforced when you mint the presigned URL and verified again on the storage side, never solely in the browser where a hostile user can bypass it. Client-side checks are for UX (tell the user \"that's too big\" before they wait); server-side checks are for safety. You need both, and you must never mistake the first for the second.\n\n## How I'd Approach It\n\nStrip it to decisions:\n\n- **Your server hands out permission, not bytes.** Use presigned URLs so the file goes browser â storage directly. Don't turn your API into a file relay.\n- **Treat the upload as a process.** Show real progress from `xhr.upload`. A moving bar is why users wait instead of refreshing.\n- **Chunk large files.** Multipart uploads make transfers resumable and remove the size ceiling. Don't hand-roll it â use the storage API via a library.\n- **The file is untrusted input.** Validate size and type on the server boundary, not just in the browser. Client checks are UX; server checks are security.\n\nThe reason file upload is a rite of passage is that the easy version and the real version look identical right up until the file gets big or the network gets bad. The input, the POST, the success toast â all of that is the 10% you can write in your sleep. The 90% is respecting that you're moving a large, untrusted, interruptible thing across a connection you don't control, to a service that should be doing the heavy carrying instead of your server. Get that shape right â permission from your backend, bytes to real storage, progress and resumability for the user â and uploads stop being the feature that falls over in production.\n\nNext, another piece of infrastructure that looks trivial and isn't: [sending email from a React app](/blog/react-playbook-mails) â why \"just send an email\" is a backend problem wearing a frontend costume, and where the real work hides.\n\nIf you've got an upload that works for small files and dies on big ones, that's the single-request ceiling, and it's the most common upload bug there is. Tell me where it breaks â the server memory, the timeout, the 90% drop â and I'll tell you which layer is missing.\n"},{id:"react-playbook-time",title:"Time in React: The Bugs That Only Show Up at 2 AM",excerpt:"Dates look like the most boring data type in your app. They are also the source of the subtlest, most embarrassing bugs â the meeting that shows an hour off, the 'today' that's tomorrow in Tokyo, the date that shifts a day every time you save it. Here's how I stop time from quietly breaking a React app.",tags:["React","Dates","Time","React Playbook"],date:"May 19, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-time/cover.png",content:"\nA date is just a value. It sits there in your data looking as harmless as a string or a number, and for a while it behaves. Then one day a user in another timezone reports that their meeting shows an hour off, or a \"created today\" badge appears on something made yesterday, or a date field creeps backward by one day every single time someone saves the form. Suddenly the most boring data type in your app is the source of the weirdest bug in your tracker.\n\nTime is deceptive because it looks simple and is not. The complexity is real and mostly invisible on the machine you develop on, because your machine has one timezone, and so the entire category of problems â timezones, daylight saving, what \"midnight\" even means â stays hidden until someone in a different part of the world hits it. And they always hit it, usually in production, usually at an hour that makes the bug's name feel personal.\n\nSo this article is about the small number of ideas that keep time from breaking a React app. Not a date-library tour â a way of thinking about time that makes the whole class of bugs stop happening.\n\n## The One Rule That Prevents Most of It\n\nIf you take nothing else from this: **store and transmit time in UTC. Convert to the user's local timezone only for display.** That's it. That single discipline, applied without exception, eliminates the majority of time bugs before they can form.\n\n> A timestamp is a fact â the exact instant something happened. A timezone is a lens for *viewing* that fact. Store the fact once, in UTC. Choose the lens at the last possible moment, at render.\n\nHere's why this works. An instant in t
1ime is absolute â the moment a user clicked \"submit\" is the same moment everywhere on Earth. What differs is how you *describe* it: 3 PM in London, 10 AM in New York, same instant. The bug factory is storing or comparing times in local terms, because then \"the same moment\" has many different labels and they drift against each other. Store the absolute fact in UTC, keep it in UTC through your database and your API, and only bend it into a human's local timezone when you're about to show it to that specific human. Timezone becomes a *display* concern, which is the only place it belongs.\n\nThe mirror-image mistake â the one that produces the \"date shifts by a day every save\" bug â is round-tripping a date through local time on the way in *and* out, so each save nudges it by the offset. Keep the storage UTC and the conversion display-only, and that whole genre of bug can't happen.\n\n## Why `new Date()` Keeps Betraying You\n\nThe native `Date` object is where good intentions go to die, and it's worth being specific about why, because the failure is silent.\n\n`Date` is implicitly local. The moment you parse or construct one, it quietly adopts the runtime's timezone â which is your laptop in development and the user's machine (or worse, a server in yet another zone) in production. So code that looks correct on your screen produces different results for different users, and you won't see it, because your machine only ever shows you your own timezone's answer.\n\n```ts\n// Looks innocent. Behaves differently depending on where it runs.\nconst d = new Date('2026-07-21'); // midnight UTC... interpreted as LOCAL\nd.getDate(); // 21 in London. 20 in New York. Same code, different day.\n```\n\nThat `getDate()` returning 20 for a New York user is the entire \"off by one day\" bug in miniature â the string is midnight UTC, but `Date` shows it through local eyes, and New York's local eyes are still on the 20th. Nothing threw an error. The value is just wrong, for some people, some of the time, which is the worst kind of wrong.\n\n## Reach for a Real Library â and Watch Temporal\n\nBecause `Date` is a minefield, the long-standing answer is a library that makes timezone handling explicit instead of implicit. [date-fns](https://date-fns.org) is my usual pick â modular, tree-shakeable, and its companion `date-fns-tz` makes conversions something you *state* rather than something that happens to you:\n\n```ts\nimport { formatInTimeZone } from 'date-fns-tz';\n\n// The instant is stored in UTC. Display it in the user's zone, explicitly.\nconst utcInstant = '2026-07-21T23:30:00Z';\nformatInTimeZone(utcInstant, 'America/New_York', 'yyyy-MM-dd HH:mm'); // \"2026-07-21 19:30\"\nformatInTimeZone(utcInstant, 'Asia/Tokyo', 'yyyy-MM-dd HH:mm'); // \"2026-07-22 08:30\"\n```\n\nNotice the same UTC instant renders as the 21st in New York and the 22nd in Tokyo â correctly, because the conversion is *explicit* and timezone-aware, not left to whatever `Date` guesses. You're stating the lens instead of inheriting it.\n\nThe genuinely good news is that the language itself is fixing this. **Temporal** â the new built-in date/time API â is designed specifically to end the `Date` era: immutable objects, explicit timezone handling, no more implicit-local footguns. It's landing in engines now, and it's the direction everything moves. If you're starting fresh, it's worth watching closely and reaching for as it stabilizes; it makes the \"be explicit about timezones\" discipline the *default* instead of something you bolt on with a library.\n\n## The Traps That Remain\n\nEven with the UTC rule and a real library, a few sharp edges stay sharp, and they're worth naming because each has bitten real apps.\n\nDaylight saving time means some days don't have 24 hours and some local times don't exist at all â the clock jumps forward and 2:30 AM simply never happened on that date. Any code doing time math by adding `24 * 60 * 60 * 1000` milliseconds and calling it \"a day\" is wrong twice a year; a real library that adds *calendar* days handles it, hand-rolled arithmetic does not.\n\n\"Midnight\" and \"today\" are not absolute either â they're timezone-bound. \"Show me everything from today\" means a different range of instants for a user in Los Angeles than one in Berlin, and if your backend computes \"today\" in the server's timezone, half your users see the wrong day's data. \"Today\" is always *someone's* today, and
1you have to decide whose.\n\nAnd serialization is where discipline leaks: the safe way to put a date on the wire is ISO 8601 in UTC (that trailing `Z`), because it's unambiguous. A date formatted for humans â or worse, a bare `new Date().toString()` â is a guessing game for whatever parses it next. Keep the API boundary UTC and ISO, and convert to human-readable strictly at the edges, at display.\n\n## How I'd Approach It\n\nStrip it to decisions:\n\n- **Store and send UTC. Convert only for display.** The single rule that prevents most time bugs.\n- **Distrust `new Date()`.** It's implicitly local, so it's implicitly wrong for someone. Don't do timezone logic with it.\n- **Use a real library** (date-fns today) and make conversions explicit. **Watch Temporal** and adopt it as it lands.\n- **Respect DST and \"today.\"** A day isn't always 24 hours, and \"today\" is always somebody's local today.\n- **Serialize as ISO 8601 UTC.** Unambiguous on the wire; humanize only at the edge.\n\nThe reason time bugs are so embarrassing is that they're invisible right up until they're not. Everything works on your machine, in your timezone, during the months without a DST transition â and then a user in Tokyo, or a clock change in November, or a \"today\" filter computed in the wrong zone quietly serves the wrong answer, and it looks like the code was never tested. It was tested. It was just tested in one timezone. Treating time as an absolute UTC fact that you view through a local lens only at the very end is the habit that makes those 2 AM bugs stop being yours.\n\nNext, a different kind of unglamorous-but-treacherous: [file uploads in React](/blog/react-playbook-file-upload) â progress, chunking, and the surprisingly deep problem of moving a large file from a browser to somewhere it can live.\n\nIf you've got a date that shifts by a day, a badge that says \"today\" when it isn't, or a meeting an hour off for some users, that's almost always a UTC-vs-local leak. Tell me where the wrong value shows up and I'll tell you which conversion is doing it.\n"},{id:"react-playbook-payments",title:"Payments in React: The Client Never Decides You Got Paid",excerpt:"Taking a payment looks like a form submission and is nothing like one. The card number is radioactive, the confirmation is asynchronous, and the one thing your React app must never do is believe it got paid because a callback said so. Here's where the real line sits between the browser, your server, and Stripe.",tags:["React","Payments","Stripe","Security","React Playbook"],date:"May 15, 2026",readTime:"12 min read",coverImage:"/blog/react-playbook-payments/cover.png",content:"\nOn the surface, taking a payment looks like the most ordinary thing in the world: a form with some fields, a submit button, a success message. You've built a hundred forms. This is just a form where one of the fields happens to be a card number.\n\nIt is not just a form, and the card number is exactly why. That field is radioactive. The moment a real card number touches your servers, you've walked into PCI compliance â a wall of security obligations that exists for very good reasons and that you do not want to be responsible for. And the \"success message\" hides the second trap: unlike every other form you've built, you don't actually know the outcome when the user clicks submit. The payment confirms *asynchronously*, somewhere you can't see, and the single most expensive mistake in this entire domain is letting your React app believe it got paid because a client-side callback said so.\n\nSo this article isn't a Stripe tutorial. It's about the two lines that matter â the one that keeps the card number away from you, and the one that decides where \"you got paid\" is actually true â because getting those two lines right is the whole job, and getting either wrong is how you end up either liable or robbed.\n\n## The First Line: Never Touch the Card Number\n\nStart with the rule that shapes everything else: **the raw card number must never reach your server.** Not to \"just pass it through,\" not to log it, not for a millisecond. The instant it does, you're in PCI-compliance scope, and that is a burde
1n measured in audits and dread, not code.\n\nThe whole architecture of a modern payments provider exists to keep you out of that scope, and the mechanism is worth understanding because it's genuinely clever. You don't build the card input. Stripe does â via [Stripe Elements](https://stripe.com/docs/payments/elements), an iframe-based field that Stripe hosts and controls. The card number is typed into *their* iframe, on *your* page, and it goes straight to Stripe. Your JavaScript can't read it. Your server never sees it. What you get back instead is a token â a harmless reference that means \"Stripe is holding a card for you\" â and *that* is what flows through your system.\n\n```tsx\nimport { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';\n\nexport function CheckoutForm() {\n const stripe = useStripe();\n const elements = useElements();\n\n async function handleSubmit(e: React.FormEvent) {\n e.preventDefault();\n if (!stripe || !elements) return;\n\n // Stripe collects the card in its own iframe and confirms directly.\n // The card number never touches your JS or your server.\n const { error } = await stripe.confirmPayment({\n elements,\n confirmParams: { return_url: 'https://example.com/order/complete' },\n });\n\n if (error) {\n // Show the user the reason. This is the \"did it start\" answer,\n // NOT the \"did it succeed\" answer.\n }\n }\n\n return (\n <form onSubmit={handleSubmit}>\n <PaymentElement />\n <button type=\"submit\">Pay</button>\n </form>\n );\n}\n```\n\nLook at what your code never handles: the card number itself. `<PaymentElement />` is Stripe's iframe living inside your form. You render it, you style around it, and you stay completely out of scope for the one piece of data that would otherwise make you liable. This is the [buy-don't-build](/blog/react-playbook-authentication) principle from the auth article at its most extreme â the risk here is so high that building it yourself isn't a trade-off, it's a mistake.\n\n## The Second Line: The Client Doesn't Decide\n\nNow the subtler and more dangerous part. The payment is initiated in the browser, but the browser is the last place that should decide whether it *succeeded*.\n\nHere's why this bites people. The naive flow: user pays, Stripe's client call resolves, you show \"Payment successful!\" and mark the order as paid in your UI. It demos perfectly. And it's exploitable and unreliable, because the client is an environment you don't control. The user closes the tab the instant before the callback fires â the card was charged, your app never knew. The network drops after the charge but before the response â same. And a hostile user can straight up call your \"mark as paid\" endpoint without paying at all, because anything the client asserts, a client can fake.\n\nThe answer is the one that runs through this whole series: **the client is untrusted; the server is the source of truth.** The real confirmation doesn't come from your React callback. It comes from Stripe, server-to-server, via a **webhook** â Stripe calls *your backend* to say \"this payment actually succeeded,\" and that server-side event is the only thing allowed to mark an order as paid.\n\n```ts\n// On your server â the ONLY place an order becomes \"paid\".\napp.post('/webhooks/stripe', async (req, res) => {\n const event = stripe.webhooks.constructEvent(\n req.body,\n req.headers['stripe-signature'],\n WEBHOOK_SECRET, // proves the call really came from Stripe\n );\n\n if (event.type === 'payment_intent.succeeded') {\n const intent = event.data.object;\n await markOrderPaid(intent.metadata.orderId); // trusted truth\n }\n\n res.json({ received: true });\n});\n```\n\nSo the client's job and the server's job split cleanly. The client *starts* the payment and shows a hopeful \"we're processing your order.\" The server, via the webhook, *confirms* it and flips the order to paid. Those are two different facts arriving through two different channels, and conflating them â treating the client's optimistic result as the settled truth â is the bug that gets apps double-charging, under-charging, or handing out product for free.\n\n> The client can tell you a payment *started*. Only the server, via the provider's webhook, can tell you it *succeeded*. Never let the browser be the thing that says \"paid.\"\n\nThis maps exactly onto the [server-state idea from the auth article](/blog/react-playbook-authentication): the important truth lives on the server, and the client holds a hopeful, possibly-stale view of it. Payments are the highest-stakes version of that same pattern, which is why the discipline matters more here than anywhere.\n\n## So How Hard Is It, Really?\n\nHonestly? Less than the fear suggests, *if* you respect the two lines. Stripe has done the genuinely hard parts â the card handling, the PCI scope, the fraud tooling. What's left for you is smaller than people expect, but it's unforgiving about correctness.\n\nYour real work is three things. Render Stripe's Element so the card never touches you â mechanical, and Stripe hands you the components. Kick off the payment from the client and, critically, treat the client-side result as \"started,\" not \"done.\" And build the webhook handler on your backend as the sole authority that marks an order paid, verifying the signature so nobody can forge that call. That's the shape of it. It's not a huge amount of code. It's a small amount of code where being casually wrong costs actual money, which is a very different kind of hard than \"lots of code.\"\n\n## How I'd Approach It\n\nStrip it to decisions:\n\n- **The card number is radioactive.** Use Stripe Elements so it never touches your JS or server. Don't negotiate with PCI scope â avoid it.\n- **The client starts the payment; it does not confirm it.** Show \"processing,\" never \"paid,\" from a client callback.\n- **The webhook is the source of truth.** An order becomes paid on your server, when Stripe tells your server it succeeded â and never before.\n- **Verify the webhook signature.** Otherwise anyone can call your endpoint and claim they paid.\n- **Buy, don't build.** This is the domain where rolling your own is not clever, it's negligent.\n\nThe reason payments feel scarier than they are is that they punish the exact habit web development otherwise rewards: trusting the client. Everywhere else, an optimistic UI that assumes success is good UX. Here, an optimistic UI that *records* success is a liability. Once you internalize that the browser can only ever say \"I tried\" and the server is the only thing that can say \"it worked,\" the whole domain stops being frightening and becomes just careful â two clean lines, respected every time.\n\nThe next article stays with the values that are deceptively easy to get wrong and quietly ruin things: [time and dates in React](/blog/react-playbook-time) â timezones, storage, and the bugs that only surface at the worst possible hour.\n\nIf you've got a checkout that marks orders paid from a client callback, that's the second line crossed, and it's worth fixing before it costs you. Tell me how your success path works and I'll tell you where the hole is.\n"},{id:"react-playbook-i18n",title:"Internationalization Isn't Translation: What Actually Breaks",excerpt:"Everyone thinks i18n means swapping English strings for a dictionary lookup. That's the 10% that's easy. The 90% is formatting, pluralization, text that changes direction, and language data you can't afford to ship all at once. Here's what internationalizing a React app actually involves once you get past the word 'translation'.",tags:["React","i18n","Internationalization","React Playbook"],date:"May 12, 2026",readTime:"12 min read",coverImage:"/blog/react-playbook-i18n/cover.png",content:"\nAsk most developers what internationalization means and you'll get the same answer: instead of writing \"Save\" in your JSX, you look up a key in a dictionary and get \"Save\" or \"Enregistrer\" depending on the user's language. And they're right â that *is* part of it. It's also the easy 10%, and it's the part that makes people underestimate the whole thing.\n\nBecause a translated string is not a translated interface. The moment you actually ship to another language, the interesting failures show up, and none of them are about the words. A date renders as `7/18/2026` to an American and `18/07/2026` to nearly everyone else. A price needs a different currency symbol, in a different position, with a comma where you had a period. \"1 item\" versus \"5 items\" is trivial in English and a genuine grammar problem in languages with six plural forms. And in Arabic or Hebrew the entire layout flips direction. None of that is a dictionary lookup.\n\nSo this article is about the 90% â what internationalizing a React app actually involves once you stop thinking of it as translation and start thinking of it as *formatting and loading*, which is what it really is.\n\n## The Reframe: It's Formatting, Not Words\n\nHere's the shift that made i18n click for me. Stop picturing a dictionary. Picture a formatting engine.\n\nThe hardest parts of internationalization aren't the strings you wrote â they're the *values* you render. A date is a value that has to be formatted for a locale. A number is a value. A currency amount is a value. A count that decides between \"item\" and \"items\" is a value running through a locale's plural rules. The English strings are static and easy; the dynamic values are where every locale disagrees, and where a naive app quietly produces wrong output that looks fine to you and broken to them.\n\n> Translation handles the words you wrote. Internationalization handles the values you render. The second one is the hard part, and it's the part people skip.\n\nThe good news is you don't implement any of this yourself. The browser ships a full formatting engine â the `Intl` API â and the React libraries are wrappers that make it ergonomic.\n\n## Formatting Values the Right Way\n\nThe instinct, when you need a date or a price in the UI, is to build the string by hand â grab the month, slap it together, append a currency symbol. That instinct is exactly the bug, be
1cause the *format itself* is locale-dependent, not just the numbers inside it.\n\nThe `Intl` API already knows every locale's rules. You give it a value and a locale; it gives you correct output:\n\n```ts\n// Dates: same instant, formatted for the user's locale.\nnew Intl.DateTimeFormat('en-US').format(date); // \"7/18/2026\"\nnew Intl.DateTimeFormat('de-DE').format(date); // \"18.7.2026\"\n\n// Currency: symbol, position, and separators all differ by locale.\nnew Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })\n .format(1234.5); // \"$1,234.50\"\nnew Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' })\n .format(1234.5); // \"1.234,50 â¬\"\n```\n\nLook at what you're *not* deciding: where the currency symbol goes, whether the thousands separator is a comma or a period, the order of day and month. You hand over the value and the locale and get correct output. Building any of that by hand isn't just more code â it's code that's confidently wrong in locales you don't personally read.\n\nThen there's pluralization, which English makes look trivial and other languages do not. \"1 item\" / \"2 items\" is a two-case rule; Polish and Arabic have far more, and a hardcoded `count === 1 ? 'item' : 'items'` is simply broken there. This is why the string libraries use the ICU message format, which encodes plural rules per locale:\n\n```\n{count, plural, one {# item} other {# items}}\n```\n\nYou write the rule once; the library picks the right form for whatever locale is active, using the same `Intl` plural data. You are never in the business of knowing Polish grammar â you're in the business of not hardcoding English grammar.\n\n## The React Side: Loading, Not Bundling\n\nNow the part that's specifically a React and architecture problem, and the one that separates a demo from a real app: **you cannot ship every language in your bundle.**\n\nHere's the trap. You wire up [react-i18next](https://react.i18next.com) or [FormatJS](https://formatjs.io), it works, and to make it work you import all your translation files at the top. Now every user downloads English, French, German, Japanese, and Arabic just to see the one language they actually use. Your bundle balloons with text 90% of users will never read, and it gets worse with every locale you add. A feature meant to serve more users made the app slower for all of them.\n\nThe right shape is to treat translation data as something you *load on demand*, the same way you'd [code-split](/blog/react-playbook-code-structure) a route. The active locale's messages load when needed; the rest stay on the server until someone actually switches:\n\n```ts\n// Load only the active locale's messages, not all of them.\nasync function loadMessages(locale: string) {\n const messages = await import(`../locales/${locale}.json`);\n return messages.default;\n}\n```\n\nThat dynamic `import` is the whole difference. A user on French downloads French. Switch to German and German loads then â a small, one-time fetch â instead of everyone paying for every language up front. The i18n libraries support this directly; the mistake is not the library, it's statically importing everything because the demo did.\n\nThis is the same instinct the whole series keeps returning to: the naive version loads everything eagerly, and the production version loads what's needed when it's needed. Translations are just data, and data that most users won't touch belongs behind a lazy load.\n\n## One More Thing: Direction\n\nA short but real one, because it's the part that turns a \"translated\" app back into an untranslated-looking one. Some languages â Arabic, Hebrew, Persian â read right-to-left, and that's not a font setting, it's a layout inversion. Your sidebar moves to the other side, your icons mirror, your text aligns the other way.\n\nThe workable answer is to stop thinking in \"left\" and \"right\" and start thinking in \"start\" and \"end.\" Modern CSS logical properties â `margin-inline-start` instead of `margin-left`, `padding-inline-end` instead of `padding-right` â flip automatically with the document direction. Set `dir=\"rtl\"` on the root for RTL locales and a layout built on logical properties reorients itself. Build it on hardcoded `left`/`right` and you get to audit every component by hand. It's cheap if you do it from the start and expensive if you retrofit â which is a good reason to reach for logical properties by default even in a single-language app.\n\n## How I'd Approach It\n\nStrip it to decisions:\n\n- **It's formatting, not translation.** The strings are easy; the values â dates, numbers, currency, plurals â are where locales actually disagree.\n- **Never format values by hand.** Use `Intl` (via your i18n library). It already knows every locale's rules; you never do.\n- **Use ICU message format for plurals.** Don't hardcode English's two-case grammar into a multi-locale app.\n- **Load locales on demand, don't bundle them all.** Dynamic-import the active language; leave the rest on the server.\n- **Build layout on logical properties.** `start`/`end`, not `left`/`right`, so RTL is a setting and not a rewrite.\n\nI used to think of i18n as a chore you tack on at the end â extract the strings, hand them to translators, done. That framing is why so many \"internationalized\" apps still show a German user an American date and an Arabic user a left-to-right layout. The strings were never the hard part. The hard part is that every locale formats values differently and reads in its own direction, and the app has to be built to bend to that instead of assuming the world works like the machine you developed on. Get the formatting and loading right and translation is the small, boring finish â which is exactly where it should sit.\n\nSpeaking of values that quietly differ everywhere and ruin your day when you get them wrong â the next article is about [handling money in React](/blog/react-playbook-payments): what taking a payment actually requires, and why the client should never be the thing that decides you got paid.\n\nIf you've got an app that \"
1supports\" multiple languages but still leaks the developer's locale â an English date here, a broken plural there â that's the formatting-not-translation gap. Tell me what's showing wrong and I'll point you at which piece is missing.\n"},{id:"react-playbook-websockets",title:"Real-Time React: When the Server Has Something to Say",excerpt:"Every data pattern so far has the client asking and the server answering. Real-time inverts that â the server speaks first, and your UI has to be listening. The mistake is treating that live connection as a second, parallel source of truth. Here's how I keep a React app live without ending up with two states that disagree.",tags:["React","WebSockets","Real-time","TanStack Query","React Playbook"],date:"May 07, 2026",readTime:"12 min read",coverImage:"/blog/react-playbook-websockets/cover.png",content:"\nEverything in this series so far has had the same conversation shape: the client asks a question, the server answers. You call `useQuery`, the request goes out, the response comes back. The client always speaks first. It's a comfortable model, and it's most of what a web app does.\n\nReal-time flips the direction. Now the server speaks first â a new message lands, a price ticks, another user drags a card across the board â and your UI has to already be listening, with no request that triggered it. That inversion is the whole thing that makes real-time feel different to build, and it's where people reach for a WebSocket and immediately make the mistake that poisons the rest of the feature.\n\nThe mistake is treating the live connection as a *second source of truth*. You already have server state, cached and managed. Then you open a socket, start catching messages, and stuff them into a separate piece of React state. Now you have two stores that are supposed to represent the same reality, updated through two different channels, and they drift. This article is about not doing that â how I wire a live connection into the state I already have, so the app stays live *and* consistent.\n\n## First: Do You Even Need a WebSocket?\n\nBefore the socket, a fork in the road, because \"real-time\" gets reached for reflexively and WebSockets are the heaviest option.\n\nThere are really three tools, and they're not interchangeable. **Polling** â just refetch on an interval â is the humble one everyone forgets. If \"real-time\" means \"within thirty seconds,\" a `refetchInterval` on an existing query is the entire feature, no new infrastructure, and you should feel no shame about it. **Server-Sent Events (SSE)** are a one-way stream from server to client over plain HTTP: perfect when the server needs to push but the client never pushes back â notifications, a live feed, status updates. **WebSockets** are the full bidirectional pipe, and you want them when *both* sides talk continuously: chat, collaborative editing, multiplayer anything.\n\nThe honest default is to start at the top of that list and only move down when the requirement forces it. A surprising number of \"we need WebSockets\" features are actually a polling interval or an SSE stream wearing a costume. Reach for the bidirectional socket when you genuinely have bidirectional, continuous communication â not before.\n\n## The Core Idea: One Source of Truth\n\nHere's the principle that makes the rest of this easy, and it's worth stating before any code:\n\n> The live connection is a delivery mechanism, not a place to store state. Messages arrive on the socket and are handed to your existing cache. They don't get their own store.\n\nYou spent the [data fetching article](/blog/react-playbook-data-fetching) setting up TanStack Query as the manager of server state. Real-time doesn't replace that â it feeds it. When a WebSocket message arrives, its job isn't to become a new piece of state you render directly. Its job is to *update the cache that's already the source of truth*. The socket is a courier. It delivers news to the store; it isn't the store.\n\nGet this one idea right and the two-sources-of-truth bug simply can't happen, because there's still only one source of truth. The socket just has a new way of writing to it.\n\n## Wiring a Socket Into the Cache\n\nConcretely, there are two moves when a message arrives, and choosing between them is most of the skill.\n\nThe blunt move is **invalidate**: a message says \"something changed over here,\" and you tell TanStack Query to refetch that query. You don't trust the message to carry the new data â you use it purely as a signal, and the refetch pulls fresh truth from the server.\n\n```tsx\nimport { useQueryClient } from '@tanstack/react-query';\nimport { useEffect } from 'react';\n\nfunction useTicketUpdates(socket: WebSocket) {\n const queryClient = useQueryClient();\n\n useEffect(() =>
1 {\n function handleMessage(event: MessageEvent) {\n const msg = JSON.parse(event.data);\n if (msg.type === 'ticket.updated') {\n // The socket is just a signal. Refetch the real data.\n queryClient.invalidateQueries({ queryKey: ['tickets', msg.ticketId] });\n }\n }\n\n socket.addEventListener('message', handleMessage);\n return () => socket.removeEventListener('message', handleMessage);\n }, [socket, queryClient]);\n}\n```\n\nInvalidation is safe and simple: the server stays authoritative, and you never render data the socket made up. The cost is an extra round trip per message, which is fine for low-frequency events and wrong for a firehose.\n\nThe sharp move is **write the message straight into the cache** with `setQueryData`, when the message already carries the full new value and refetching would be wasteful â a chat message, a price tick:\n\n```tsx\nfunction handleMessage(event: MessageEvent) {\n const msg = JSON.parse(event.data);\n if (msg.type === 'chat.message') {\n queryClient.setQueryData(['chat', msg.roomId], (old: Message[] = []) => [\n ...old,\n msg.message,\n ]);\n }\n}\n```\n\nSame principle either way â the socket updates the one cache â but `setQueryData` skips the round trip by trusting the payload. My rule of thumb: **invalidate for \"something changed,\" write directly for \"here is exactly what changed.\"** High-frequency streams where a refetch-per-message would melt your server are the clear case for writing directly; everything else, start with invalidate because it's harder to get wrong.\n\n## The Part Everyone Underestimates: The Connection Lifecycle\n\nOpening a socket is the easy 20% again. The other 80% is that connections are *not reliable*, and a real-time feature is only as good as how it handles a connection that isn't there.\n\nThe network drops. The laptop sleeps and wakes. A deploy restarts the server and every socket dies at once. If your feature assumes a persistent, healthy connection, it silently stops updating and the user has no idea â which is worse than a visible error, because they're now looking at stale data they believe is live. So the lifecycle work is not optional polish; it's the feature. You need **reconnection** with backoff so a blip heals itself instead of ending the session. You need to **resync on reconnect** â when the socket comes back, invalidate the relevant queries and refetch, because you almost certainly missed messages while it was down, and the socket can't replay history it never delivered. And you owe the user **connection status** in the UI, even something quiet, so \"live\" and \"trying to reconnect\" don't look identical.\n\nThis is exactly why not hand-rolling the raw `WebSocket` for anything serious is the right call. A library like [Socket.IO](https://socket.io) exists to handle reconnection, fallbacks, and connection state for you â the same trade as every other article in this series, where the value isn't the raw capability but the library that manages its messy edges. Raw `WebSocket` is fine to learn on and fine for a toy. For a feature people depend on, let something else own the reconnection logic, and spend your attention on what the messages *mean*.\n\n## How I'd Approach It\n\nStrip it to decisions:\n\n- **Don't reach for WebSockets reflexively.** Polling and SSE cover more cases than people admit. Use the bidirectional socket only for genuinely bidirectional, continuous features.\n- **The connection is a courier, not a store.** One source of truth stays your TanStack Query cache; the socket writes to it.\n- **Invalidate for \"something changed,\" `setQueryData` for \"here's exactly what changed.\"** Start with invalidate; reach for direct writes when the frequency demands it.\n- **The lifecycle is the feature.** Reconnect with backoff, resync on reconnect, show connection status. A silently dead socket is worse than an error.\n- **Let a library own the connection.** Hand-rolled raw sockets are for learning, not for things people rely on.\n\nThe thing that used to make real-time feel hard, for me, was imagining it as a whole separate system bolted onto the app â its own state, its own rules, its own bugs. It isn't. It's one new way for data to arrive into the store you already have. Once the socket stops being a source of truth and becomes a courier delivering to your cache, the \"two states that disagree\" class of bug disappears, and what's left is just careful handling of a connection that can drop. That part takes discipline, but it's a known problem with known answers.\n\nNext up, a different kind of \"the same app, but harder\": [internationalization in React](/blog/react-playbook-i18n) â which turns out to be far less about translating strings and far more about formatting, loading, and everything that quietly breaks when your app leaves one language.\n\nIf you've got a real-time feature where the UI occasionally shows stale data or two views disagree, that's almost always a two-sources-of-truth problem or a missed-resync-on-reconnect problem. Tell me the symptom and I'll tell you which.\n"},{id:"react-playbook-ai",title:"AI in a React App: Past the Demo, Into the Product",excerpt:"The demos are intoxicating â a chat box, a streaming response, thirty lines of code and it feels like magic. The
1n you try to ship it and the hard parts show up all at once: streaming UI, structured output you can trust, state that doesn't fight you. Here's what building AI into a real React app actually takes.",tags:["React","AI","TanStack","Streaming","React Playbook"],date:"May 04, 2026",readTime:"13 min read",coverImage:"/blog/react-playbook-ai/cover.png",content:"\nThe first AI demo you build feels like cheating. A text input, a fetch to a model, a response on screen â thirty lines, maybe less, and suddenly your app *talks*. It's genuinely exciting, and that excitement is exactly the trap. Because the demo is the easy 20%. The moment you try to put that chat box in front of real users, the other 80% arrives all at once, and none of it is about the model.\n\nThe response doesn't appear all at once â it streams, token by token, and your UI has to handle that gracefully. You don't just want text back, you want *structured* data you can render into a component, and the model doesn't always cooperate. The request takes seconds, sometimes fails halfway, and your app has to stay honest about loading and error states the whole time. This is the part the demos skip, and it's the entire job.\n\nSo this article isn't \"how to call an LLM.\" Calling the model is trivial. This is about the React-side reality of turning that call into something you'd actually ship â the streaming, the structure, the state â and where the current tools genuinely help versus where you're still on your own.\n\n## Why AI Broke My Normal Data Layer\n\nUp to this point in the series, every data problem had the same shape. You fire a request, you wait, you get a response, [TanStack Query](/blog/react-playbook-data-fetching) caches it and hands it to your component. Request, response, done. It's a clean model and it covers almost everything.\n\nAI breaks it in one specific way: the response isn't a single event, it's a *stream*. The model produces its answer incrementally, and the whole point â the thing that makes it feel alive â is showing those tokens as they arrive. A spinner that sits for eight seconds and then dumps a wall of text feels broken, even when it's technically correct. The perceived quality of an AI feature is almost entirely about the streaming.\n\nThat's a different primitive than request/response. You're not awaiting a value; you're consuming a sequence over time and rendering each chunk. Trying to force that into a normal `useQuery` fights the grain of the tool. This is why AI-specific React libraries exist â not because calling a model is hard, but because *streaming a model's output into a UI* is a genuinely different problem, and doing it by hand means manually managing readers, buffers, partial state, and cancellation.\n\n## The Streaming Chat, Handled For You\n\nThe good news is that this exact problem is now well-solved. The [Vercel AI SDK](https://ai-sdk.dev) (and the TanStack-flavored patterns around it) exists precisely to make streaming chat a solved problem on the React side. Its `useChat` hook is the `useQuery` of this world â it owns the messy part so you don't.\n\n```tsx\nimport { useChat } from 'ai/react';\n\nexport function SupportChat() {\n const { messages, input, handleInputChange, handleSubmit, isLoading } =\n useChat({ api: '/api/chat' });\n\n return (\n <div className=\"support-chat\">\n {messages.map((m) => (\n <div key={m.id} data-role={m.role}>\n {m.content}\n </div>\n ))}\n\n <form onSubmit={handleSubmit}>\n <input value={input} onChange={handleInputChange} disabled={isLoading} />\n <button type=\"submit\" disabled={isLoading}>Send</button>\n </form>\n </div>\n );\n}\n```\n\nLook at what you *didn't* write. No stream reader. No manual buffering of partial tokens. No hand-rolled \"append this chunk to the last message\" logic. `messages` updates as tokens arrive, so the response types itself onto the screen. `isLoading` gives you the state to disable the input and show activity. The entire streaming problem â the thing that would take a day to get right by hand and stay subtly buggy â is inside the hook.\n\nThis is the same lesson as every other article in this series: the value isn't the raw capability, it's a library that takes the awkward part and g
1ives you a clean React interface for it. `useChat` is to streaming what TanStack Query is to caching.\n\n## The Part That Actually Trips People Up: Structured Output\n\nStreaming text is solved. Here's where it gets real: most of the time you don't want a paragraph of prose, you want *data*. You want the model to extract three fields from an invoice, or classify a support ticket, or return a list of suggested tags â and then you want to render that into an actual component, not a string.\n\nThe naive approach is to ask the model for JSON in the prompt and `JSON.parse` the result. Do this in a demo and it works. Do it in production and it breaks, because the model is a text generator, not an API. Sometimes it wraps the JSON in ````json fences. Sometimes it adds a friendly \"Sure, here's the data:\" preamble. Sometimes it hallucinates a field. `JSON.parse` throws, and now your feature is down because the model was chatty.\n\nThe current answer is to stop hoping and start enforcing. You define a [Zod](/blog/react-playbook-form-libraries) schema for the shape you need and let the SDK constrain the model to it:\n\n```ts\nimport { generateObject } from 'ai';\nimport { z } from 'zod';\n\nconst ticketSchema = z.object({\n category: z.enum(['billing', 'technical', 'account', 'other']),\n priority: z.enum(['low', 'medium', 'high']),\n summary: z.string(),\n});\n\nconst { object } = await generateObject({\n model,\n schema: ticketSchema,\n prompt: `Classify this support ticket: ${ticketText}`,\n});\n\n// object is typed as { category: ..., priority: ..., summary: string }\n// and validated â not a hopeful JSON.parse.\n```\n\nThe difference is the difference between a demo and a product. `object` isn't a string you're praying parses â it's a validated, typed value the SDK guarantees matches your schema, or the call fails loudly instead of feeding garbage into your UI. Notice this is the *exact same Zod schema pattern* from the forms article, pointed at a new problem. You already know this tool. The model becomes just another untrusted input source you validate at the boundary â which, if you've read the [authentication](/blog/react-playbook-authentication) piece, is exactly how you should treat everything crossing into your app.\n\n> Treat model output like user input, not like an API response. It is text that happens to look structured. Validate it, or it will embarrass you in production.\n\n## Where the Tools Stop and You Start\n\nI want to be honest about the edges, because the ecosystem is young and the demos oversell.\n\nThe streaming and structured-output tools are solid. What they don't solve is everything *around* the call. Cost â every token is money, and a chat feature with no limits is a bill waiting to happen; you need throttling and usage caps, and those are your problem, not the SDK's. Latency â responses take seconds, and no amount of UI polish makes a slow model fast; you design around the wait, you don't remove it. Failure â models time out, hit rate limits, and return nonsense, so every AI feature needs the same [error handling](/blog/react-playbook-error-handling) discipline as any other flaky network dependency, plus a graceful answer for \"the model gave me something useless.\"\n\nAnd the honest constraint underneath all of it: the model is non-deterministic. The same input can produce different output. That breaks a core assumption every other part of your app relies on. You can't snapshot-test it the normal way, you can't guarantee a given response, and you have to design UI that stays usable when the answer is wrong. That's not a library gap that'll be patched next quarter. That's the nature of the thing, and building AI features well means designing for it rather than pretending it away.\n\n## How I'd Actually Approach It\n\nStrip it to decisions:\n\n- **Streaming is the product, not a detail.** The feel of an AI feature is the streaming. Don't fake it with a spinner.\n- **Don't hand-roll the stream.** Use `useChat` / the AI SDK. It's the `useQuery` of streaming â it owns the messy part.\n- **Never trust free-form JSON.** Use `generateObject` with a Zod schema. Treat model output as untrusted input, validated at the boundary.\n- **Budget for cost, latency, and failure from day one.** They're your responsibility, not the SDK's, and they don't go away.\n- **Design for non-determinism.** The answer can be wrong. The UI has to survive that.\n\nThe demos make AI look like a feature you drop in. It isn't. It's a new kind of data source â streaming, untrusted, non-deterministic, and metered â and the work is treating it with the same engineering seriousness you'd give any other unreliable dependency. The tools have gotten genuinely good at the streaming and the structure. The judgment â where to use it, how to fail, what to guarantee â is still entirely yours, and that's the part worth getting right.\n\nThe next article stays in real-time territory but drops the AI: [WebSockets and real-time in React](/blog/react-playbook-websockets) â how to keep a UI live when the server has something to say, and how it fits with the TanStack Query cache you already have.\n\nIf you're building an AI feature and it works beautifully in the demo but falls apart with real traffic, that gap is almost always one of these â streaming, structure, or the metered/non-deterministic reality underneath. Tell me where it breaks and I'll tell you which one it is.\n"},{id:"react-playbook-testing",title:"Testing React Without Drowning: What Each Test Actually Buys You",excerpt:'Most testing advice is either "100% coverage" zealotry or "tests slow you down" cynicism. Both miss the point. A test is a trade â effort now for confidence later â and the skill is knowing which trades pay off. Here\'s how I test React without a brittle suite that everyone hates.',tags:["React","Testing","Vitest","Testing Library","React Playbook"],date:"Apr 30, 2026",readTime:"13 min read",coverImage:"/blog/react-playbook-testing/cover.png",content:"\nTwo developers look at the same untested codebase and reach opposite conclusions. One says \"this is reckless, we need 100% coverage before we ship anything else.\" The other says \"tests are why we're slow, they break every time we change a line, let's just be careful instead.\" I've been in rooms with both, and here's the uncomfortable thing: they're both partly right, and both are optimizing the wrong variable.\n\nBecause a test isn't a virtue and it isn't a tax. It's a trade â effort and speed now, in exchange for confidence and safety later. Some of those trades are wildly w
1orth it. Some cost more than they'll ever return. The whole skill of testing isn't writing tests; it's knowing which trade you're making and whether it pays off.\n\nSo this article isn't a testing tutorial and it definitely isn't a coverage sermon. It's how I actually decide what to test in a React app, so I end up with a suite that catches real bugs without becoming the brittle, resented thing that everyone works around.\n\n## The Pain Each Kind of Test Removes\n\nForget the pyramid diagram for a second. The useful question isn't \"what are the types of tests,\" it's \"what specific pain does each one remove.\" Because if a test isn't removing pain, you're paying for nothing.\n\n**Unit tests** remove the pain of a single piece of logic being subtly wrong. A pricing calculation, a date formatter, a reducer with tricky edge cases. Fast, focused, and they pin down the exact spot a bug lives.\n\n**Component tests** remove the pain of a UI piece behaving wrong when a user touches it. Does the form show the error, does the button disable while submitting, does the list render what it's handed. This is where most React testing value actually lives, and we'll spend the most time here.\n\n**End-to-end (E2E) tests** remove the pain of the whole thing being broken even though every piece works alone. The classic disaster: every unit passes, and login is still broken because two correct parts don't fit together. E2E drives a real browser through a real flow and catches exactly that.\n\nEach targets a different fear. Unit: \"is this logic right?\" Component: \"does this behave when used?\" E2E: \"does the actual journey work?\" You don't need all three everywhere. You need each one where its specific fear is real.\n\n## The One Rule That Fixed My Component Tests\n\nFor a long time my React tests were brittle. I'd change an implementation detail â rename a state variable, restructure a handler, swap a class name â and a dozen tests would go red without a single actual bug. That's the experience that makes people hate testing, and it turns out it was my fault, not testing's.\n\nThe rule that fixed it: **test behavior, not implementation. Test what the user experiences, not how the component achieves it.**\n\n[React Testing Library](https://testing-library.com) is built entirely around this idea, and once it clicked, my tests stopped breaking for stupid reasons. You don't query for internal state or component instances. You query the DOM the way a user perceives it â by text, by role, by label â and you interact the way a user does.\n\n```tsx\nimport { render, screen } from '@testing-library/react';\nimport userEvent from '@testing-library/user-event';\n\ntest('shows an error when submitting an empty policy holder name', async () => {\n render(<PolicyForm />);\n\n // Interact like a user: find the button by its accessible role.\n await userEvent.click(screen.getByRole('button', { name: /save/i }));\n\n // Assert on what the user actually sees.\n expect(screen.getByText(/holder name is required/i)).toBeInTheDocument();\n});\n```\n\nLook at what that test doesn't know. It doesn't know if `PolicyForm` uses `useState` or a form library, whether the error lives in local state or context, what the handler is called. I can rewrite the entire internals of that component and, as long as an empty submit still shows that error, the test stays green. That's the point: the test is coupled to the *behavior the user relies on*, not the code I happened to write. Implementation-coupled tests break when you refactor; behavior-coupled tests break when you regress. Only one of those is useful.\n\nThis connects straight back to the [forms article](/blog/react-playbook-form-libraries): the reason I could confidently recommend a specific form library is that behavior tests let me swap implementations without rewriting the test suite. Good tests make good architecture decisions cheaper to reverse.\n\n## Vitest, Quickly\n\nA note on tooling, because it changed and the old advice lingers. For years Jest was the default. Today, for a Vite-based app â which is most new React apps â **Vitest** is the natural pick. It reuses your existing Vite config and transforms, so there's no separate parallel build setup to maintain, and it's fast. The API is close enough to Jest that everything you know transfers. Pair it with React Testing Library and you have the whole component-testing stack. I won't belabor it: if you're on Vite, use Vitest, and move on to the part that matters, which is deciding what to test.\n\n## What I Actually Test, and What I Don't\n\nHere's where the \"it's a trade\" framing turns into decisions. Not everything deserves a test, and pretending otherwise is how you get a slow, resented suite.\n\n**I test the things that are painful when wrong.** Business logic with real consequences â pricing, permissions, anything money- or data-integrity-related. Complex conditional UI, the kind with enough branches that I can't hold them in my head. Bugs I've already fixed once, which get a regression test so they can't come back. Custom hooks with real logic. These earn their keep because the cost of them silently breaking is high.\n\n**I don't test the things that are cheap to verify by looking.** A component that renders a prop into a heading. Trivial passthrough. Third-party libraries â that's their job, not mine. Static markup with no logic. Writing a test for `<h1>{title}</h1>` doesn't remove any pain; it just adds a file that has to change every time the markup does.\n\nThe honest heuristic I use: **before writing a test, I ask what bug it would catch and how bad that bug would be.** If I can't name a realistic bug, or the bug would be obvious and harmless, I skip it. If the bug would be subtle, expensive, or embarrassing, I write the test first. Coverage percentage never enters the decision â a codebase at 100% coverage full of `<h1>` tests is worse than one at 60% that covers every pricing path, because the first one is mostly noise you have to maintain.\n\n> Coverage measures how much code ran during tests. It says nothing about whether the tests would catch a bug that matters. Chasing the number optimizes the wrong thing.\n\n## E2E: A Little Goes a Long Way\n\nEnd-to-end tests are the most expensive to write and maintain and the slowest to run, so I'm deliberately stingy with them â but the few I keep are the ones I'd least want to live without.\n\nI don't E2E every screen. I E2E the handful of flows where \"broken and nobody noticed\" would be a genuine disaster: the signup and login path, the checkout or core conversion flow, the one or tw
1o journeys the business actually runs on. Tools like Playwright drive a real browser through those, catching the integration failures that unit and component tests structurally can't see.\n\n```ts\nimport { test, expect } from '@playwright/test';\n\ntest('a user can sign in and reach the dashboard', async ({ page }) => {\n await page.goto('/login');\n await page.getByLabel('Email').fill('[email protected]');\n await page.getByLabel('Password').fill('correct-horse');\n await page.getByRole('button', { name: /sign in/i }).click();\n\n await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();\n});\n```\n\nNotice it's the same philosophy as the component test â interact by role and label, assert on what the user sees â just scaled up to the whole journey through a real browser. A small number of these, guarding the flows that matter most, buys an enormous amount of confidence for the cost. Try to E2E everything and you get a slow, flaky suite people disable. Guard the critical few and you get a safety net people trust.\n\n## How This All Fits Together\n\nStrip it to decisions:\n\n- **A test is a trade, not a virtue.** Effort now for confidence later â only worth it when the confidence is worth more than the effort.\n- **Match the test to the fear.** Unit for logic, component for behavior, E2E for the critical journey.\n- **Test behavior, not implementation.** Query and interact like a user, so refactors don't turn your suite red for no reason.\n- **On Vite, use Vitest** with React Testing Library, and stop thinking about tooling.\n- **Ask \"what bug would this catch, and how bad is it?\"** before writing any test. Skip the ones with no honest answer.\n- **Ignore the coverage number.** Cover what hurts when it breaks.\n\nI used to think the goal of testing was a high coverage number, and I wrote a lot of useless tests chasing it. The goal is confidence â the specific, earned feeling that you can change this code and not break something that matters. A suite that gives you that at 60% honest coverage is worth more than one that gives you false comfort at 100%. Tests aren't there to make a dashboard green. They're there so you can move fast without being afraid, and every test that doesn't serve that is just weight you're carrying.\n\nThis is the last piece of the \"build a complete app\" arc â start a project, structure it, fetch data, route, manage state, style, build forms and charts, authenticate, talk to a backend, render on the server, store data, deploy, and now test it. From here the series turns to the sharper edges: the next article is about [React and AI](/blog/react-playbook-ai) â what the current tools actually make possible in a real app, past the demos.\n\nIf you've got a test suite that everyone quietly works around â slow, brittle, red for the wrong reasons â that's usually a \"testing implementation instead of behavior\" problem, and it's fixable. Tell me what breaks when you refactor, and I'll tell you what I'd change.\n"},{id:"react-playbook-hosting-railway",title:"Hosting a React App in 2026: From DevOps Project to Git Push",excerpt:"Deployment used to be a separate discipline â servers, pipelines, a person whose whole job was getting code to production. Modern platforms compressed it into a git push. Here's how to think about hosting a React app, what the tiers actually mean, and where the magic stops.",tags:["React","Hosting","Deployment","Railway","React Playbook"],date:"Apr 27, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-hosting-railway/cover.png",content:"\nThe first time I deployed something to production, it took a week and a person whose entire job was making deployments happen. There was a server to configure, a pipeline to wire, environment variables to get wrong in six interesting ways, and a deploy checklist that read like a pre-flight sequence. Shipping was an event.\n\nThe last thing I deployed took a git push. No server, no pipeline I wrote, no checklist. I pushed to `main`, and a minute later it was live with a URL and HTTPS I didn't configure. Same person, same nervousness the first time â and almost none of the work.\n\nThat compression is the story of modern hosting, and it's easy to take for granted until you realize it quietly changed who gets to ship things. When deployment stops being a discipline, a single developer can put a real, full-stack app in front of real users without a DevOps team standing behind them. So this article is about how to think about hosting a React app now â not a tutorial for one platform, but the mental model, the tiers, and the place where the magic politely stops.\n\n## First, What Are You Actually Deploying?\n\nThe most common hosting confusion isn't about platforms. It's that \"deploy my React app\" means two completely different things depending on what you built, and picking the wrong kind of host is where people get stuck.\n\n**A static site.** If your app is client-side rendered or statically generated â this very blog is a static export â then \"deploying\" is just serving files. HTML, CSS, JS, sitting on a CDN. There's no server running your co
1de; there's a very fast file server near your users. This is the cheapest, simplest, most durable thing you can ship, and platforms like GitHub Pages, Netlify, Cloudflare Pages, and Vercel do it beautifully, often free.\n\n**An app with a server.** The moment you have [SSR](/blog/react-playbook-ssr-tanstack-start), server functions, or a [backend and database](/blog/react-playbook-database-turso), there's actual code that has to run on demand, somewhere, all the time. That's not a file server. That's a running process, and it needs a host that keeps it alive, scales it, and gives it access to your database.\n\nAlmost every hosting mistake I see is someone trying to fit one of these into the other's home â pushing a full-stack app onto a static-only host and wondering why the server code never runs, or paying for an always-on server to deliver what is really just a folder of files. Know which one you have first. Everything else follows from that.\n\n## The Static Path: Basically Solved\n\nIf you're shipping a static site, the good news is you barely have to think. Connect your Git repo to Netlify, Cloudflare Pages, Vercel, or GitHub Pages; every push to your main branch builds and deploys automatically; you get a global CDN, HTTPS, and preview URLs for pull requests without configuring any of it.\n\nThis series' own site is the proof â a Next.js static export pushed to a branch, built by CI, served from a CDN, on a custom domain. There is no server. There is nothing to keep running. And that's exactly why it's the most robust option: the thing that can't crash at 3am is the thing that isn't running. If your app can be static, let it be static, and enjoy how little there is to worry about.\n\n## The Full-Stack Path: Where Railway Comes In\n\nThe interesting decisions start when you have a server to run. Now you need a platform that takes your code, containerizes it, keeps the process alive, connects it to a database, manages environment variables and secrets, and scales it when traffic climbs â all without you learning Kubernetes.\n\nThis is the category **Railway** lives in, alongside Render, Fly.io, and the more serverless-shaped Vercel functions model. What they share is the same compression that hit static hosting, now applied to running servers: you connect a repo, and a git push builds and deploys a live process.\n\n```bash\n# The entire \"deploy a full-stack app\" story, conceptually:\ngit push # push to the connected branch\n# platform detects the app, builds a container,\n# injects env vars, runs it, gives it a URL and HTTPS\n```\n\nWhat I like about this generation of platforms is that they collapse the app *and* its dependencies into one place. On Railway you can provision your database right next to your app and wire them together in the dashboard, so the [Turso-or-Postgres decision from the last article](/blog/react-playbook-database-turso) and the hosting decision stop being two separate projects. The whole backend â process, database, environment â lives in one connected picture instead of a pile of services you glued together by hand. For a solo developer or a small team, that consolidation is the actual product.\n\nI'm not going to crown one winner. Railway is excellent for full-stack apps that want a database and a server in one tidy place; Fly.io leans toward running close to users at the edge; Vercel is the smoothest possible home for a Next.js app specifically. The right pick depends on your stack, and the honest advice is to match the platform to the shape of what you built, not to the loudest brand.\n\n## Environment Variables: The One Thing to Get Right\n\nIf there's a single operational skill that separates a smooth deploy from a painful one, it's handling configuration correctly â and it's where security quietly rides along.\n\nThe rule is simple and non-negotiable: **secrets go in the platform's environment variables, never in your code, never in your repo.** Your database URL, your auth tokens, your API keys â those live in Railway's (or whoever's) settings, injected into the process at runtime.\n\nAnd there's a React-specific trap worth calling out, because it burns people. Anything your frontend bundle can read is public â it ships to the browser, where anyone can open it and look. Frameworks make this explicit with prefixes: a variable exposed to the client is deliberately marked as such.\n\n```bash\n# Safe on the server only â never reaches the browser:\nDATABASE_URL=...\nAUTH_SECRET=...\n\n# Deliberately public â bundled into the client, so treat as visible:\nNEXT_PUBLIC_ANALYTICS_ID=...\nVITE_PUBLIC_APP_URL=...\n```\n\nThat prefix is a security boundary, not a naming convention. A secret with a public prefix is a secret you've published. This connects straight back to the [authentication article](/blog/react-playbook-authentication): the same discipline of knowing what the browser can and cannot see applies to every environment variable you set. Get this one habit right and you've avoided the most common way small teams leak credentials.\n\n## Where the Magic Stops\n\nI've spent this article celebrating how easy hosting got, so let me be honest about where the smooth part ends, because pretending it doesn't is how people get surprised.\n\nAuto-scaling is real, but it isn't free or infinite â traffic spikes cost money, and a runaway process or a bad loop can run up a bill while you sleep.
1Cold starts are real on serverless tiers: a function that hasn't run in a while takes a beat to wake, and for some apps that latency matters. Databases have connection limits that serverless functions, which spin up many instances, can exhaust in ways that surprise you. And \"it deployed\" is not \"it works\" â you still own monitoring, logging, and knowing when production is unhappy.\n\nNone of this brings back the week-long deploy. It just means the modern platform moved your attention from *how do I ship this* to *how does this behave once it's shipped*, which is a much better problem to have. The floor got higher. The ceiling is still yours to worry about.\n\n## The Takeaway\n\nBoil it down:\n\n- **Know what you're deploying.** Static site or running server â that single fact chooses your host.\n- **If it can be static, let it be static.** The cheapest, most durable option is the one with nothing running.\n- **For full-stack, use a platform that consolidates app and database.** Railway, Render, Fly.io â a git push to a live process, database next door.\n- **Secrets in env vars, and respect the public prefix.** It's a security boundary, not a style choice.\n- **The magic stops at runtime behavior.** Scaling cost, cold starts, connection limits, monitoring â still yours.\n\nI started this series' thread on infrastructure by admitting the database used to scare me. Hosting used to scare me for the same reason â it looked like a separate profession guarding the door to production. It isn't anymore. The tools compressed a week of DevOps into a git push, and that didn't just save time; it moved the power to ship real software into the hands of individual developers. You can build the app, store its data, and put it in front of the world, alone, in an afternoon. That's genuinely new, and it's worth not taking for granted.\n\nThe app is built, stored, and deployed. Now we make sure it stays working â the next article is about [testing in React](/blog/react-playbook-testing): the kinds of tests, the pain each one actually removes, and how to test without drowning in brittle suites.\n\nIf you've got an app that's stuck at the \"how do I actually put this online\" step, that used to be a wall and now it's a git push â tell me what you built and I'll point you at where it should live.\n"},{id:"react-playbook-database-turso",title:"Databases for React Developers: What Serverless Actually Changed",excerpt:'"Add a database" used to mean provisioning a server, managing connections, and running migrations by hand. Serverless quietly rewrote that whole sentence. Here\'s what a React developer actually needs to know about picking and using a database in 2026.',tags:["React","Database","Turso","SQLite","React Playbook"],date:"Apr 23, 2026",readTime:"12 min read",coverImage:"/blog/react-playbook-database-turso/cover.png",content:"\nThere's a specific fear a lot of frontend developers have, and I had it too: the database is where I'm not supposed to go. Tables, indexes, connection pools, migrations, the DBA who guards it all â it reads like someone else's territory, and the safe move is to stay in the browser where I belong.\n\nThat fear made sense a decade ago. It doesn't anymore, and holding onto it now quietly caps what you can build alone. Because \"add a database\" used to be a genuinely heavy sentence â provision a server, keep it running, manage a pool of connections, hand-run migrations, worry about it at 3am. Serverless rewrote every clause of that sentence, and most of the weight is just gone.\n\nSo this article is the database explainer I wish someone had handed me when I still thought data storage was off-limits. Not how to be a DBA â how to be a React developer who can pick a database, reason about it, and ship a full app without asking permission.\n\n## The Fear Was Really About Operations\n\nHere's the thing I got wrong for years: my fear of databases wasn't about SQL. SQL is learnable in a weekend. It was about *operations* â the running, scaling, backing-up, not-losing-everyone's-data part. That's the genuinely hard, genuinely scary part, and it's exactly the part serverless takes off your plate.\n\nA serverless database is one you don't run. No server to provision, no connection pool to babysit, no capacity to plan. It scales when you're busy, costs almost nothing when you're idle, and someone else owns the 3am pager. What's left for you is the part that was never scary: describing your data and querying it.\n\nOnce I understood that the wall was operational, not intellectual, databases stopped being forbidden and started being just another tool I could reach for. That reframe is the whole point of this article.\n\n## The Types, Quickly and Honestly\n\nYou need a rough map before names mean anything. There are more categories than this, but for building a React app, three matter.\n\n**Relational (SQL)** â Postgres, MySQL, SQLite. Data in tables with defined relationships. This is the default, and it should be. It's proven, it's flexible, and the guarantees are strong. When you don't have a specific reason to pick something else, pick this.\n\n**Document (NoSQL)** â MongoDB, Firestore. Data as flexible JSON-ish documents, no rigid schema up front. Genuinely useful when your data is loosely structured or you're moving fast before the shape settles. My honest take: teams reach for it to \"avoid schemas\" and later discover the schema didn't disappear, it just moved into their application code where the database can't help enforce it.\n\n**Key-value** â Redis. Dead-simple pairs, blazing fast, usually a *complement* to a real database for caching and sessions, not your source of truth.\n\nIf you're building a typical app and unsure, the boring answer is right: a relational database. The interesting question in 2026 isn't SQL versus NoSQL â it's what serverless did to the relational option.\n\n## Why SQLite Came Back\n\nFor years SQLite was filed under \"the little embedded database\" â great for a phone app or a local file, not something you'd run a real web product on. That mental filing is now out of date, and it's worth understanding why, because it's the shift this whole article turns on.\n\nSQLite is spectacularly simple and fast â the entire database is a file. What it historically lacked was a way to be that simple *and* live in the cloud serving a distributed a
1pp. That's the gap platforms like **Turso** fill: SQLite's simplicity, rebuilt for the edge, replicated close to your users, with the operational weight removed.\n\n```ts\nimport { createClient } from '@libsql/client';\n\nconst db = createClient({\n url: process.env.DATABASE_URL!,\n authToken: process.env.DATABASE_AUTH_TOKEN,\n});\n\n// This is the whole \"connect to your production database\" story.\nconst policies = await db.execute('SELECT * FROM policies WHERE user_id = ?', [userId]);\n```\n\nLook at what isn't there. No pool configuration, no connection lifecycle, no server to have provisioned first. A URL and a token, and you're talking to a replicated production database. The thing that scared me â the operations â has been compressed into two environment variables. That's the serverless shift made concrete.\n\nI'm not claiming Turso is the only right answer; serverless Postgres options like Neon do the same trick for the Postgres world, and if your heart is set on Postgres, reach for one of those instead. The point isn't the specific vendor. It's that the operational barrier that made databases feel off-limits has genuinely fallen, whichever ecosystem you pick.\n\n## Don't Write SQL Strings by Hand â Use an ORM\n\nThe one place I'll push you toward a specific practice: put a typed query layer between your React-adjacent code and the raw database. In the TypeScript world that means an ORM like **Drizzle** or **Prisma**, and the reason is the same thread running through this entire series â types.\n\nRaw SQL strings are stringly-typed. A typo in a column name is a runtime error you find in production. A typed ORM turns that into a compile error you find while typing.\n\n```ts\nimport { drizzle } from 'drizzle-orm/libsql';\nimport { eq } from 'drizzle-orm';\n\nconst db = drizzle(client);\n\n// Fully typed: 'policies.userId' is checked, the result is typed,\n// a renamed column breaks the build, not production.\nconst rows = await db.select().from(policies).where(eq(policies.userId, userId));\n// ^? Policy[] â inferred from the schema\n```\n\nThis should feel deeply familiar by now. It's the exact move from the [tRPC and server-functions articles](/blog/react-playbook-backend): define the shape once, let the compiler carry it everywhere. Your database schema becomes a TypeScript source of truth, the same types flow from the table into your query into your [TanStack Query](/blog/react-playbook-data-fetching) cache and finally into your component. One definition, enforced end to end, from the database row to the rendered pixel. That's the coherence this series keeps chasing, now extended all the way down to storage.\n\n## Migrations Aren't Scary Either\n\nThe last piece of the old fear: migrations â changing your schema without breaking the running app. This used to be hand-written SQL run carefully against production, and it's where \"I might delete everything\" lives.\n\nModern ORMs largely defuse it. You change your schema definition in TypeScript, and the tool generates the migration for you.\n\n```bash\n# Change the schema in code, then:\nnpx drizzle-kit generate # generates the migration from the diff\nnpx drizzle-kit migrate # applies it\n```\n\nYou still have to *think* â a migration that drops a column is still destructive, and no tool saves you from a bad decision. But the mechanical, error-prone part of hand-writing schema changes is handled, and the migration lives in your repo as a reviewable, version-controlled file instead of a command someone typed into a production shell and forgot.\n\n## What a React Developer Should Actually Take Away\n\nStrip it down:\n\n- **The wall was operational, not intellectual.** Serverless removed the running-and-scaling part that was actually hard.\n- **Default to relational.** Reach for document or key-value only when you have a specific reason.\n- **SQLite is a real option now.** Turso (or serverless Postgres like Neon) gives you production data storage behind a URL and a token.\n- **Use a typed ORM.** Drizzle or Prisma make your schema a TypeScript source of truth, and the types flow all the way to your components.\n- **Migrations are generated, not hand-written
1** â but you still own the judgment.\n\nI spent too long treating the database as someone else's job, and all it did was make me dependent on someone else to ship anything real. The serverless shift didn't just make databases easier â it made them *mine*, and yours. You don't need a DBA and a provisioning ticket to store data anymore. You need a schema, a typed client, and the willingness to walk through a wall that isn't there. That, more than any single tool, is what changed.\n\nWith storage handled, the app needs to actually run somewhere the world can reach it. The next article is about [hosting a React app](/blog/react-playbook-hosting-railway) â how modern platforms turned deployment from a DevOps project into a git push, and where the limits still bite.\n\nIf you're sitting on an app idea and the database is the part that's stopping you, that's the exact fear I'm describing â tell me what you're trying to store and I'll help you pick where it goes.\n"},{id:"react-playbook-ssr-tanstack-start",title:"SSR in React: What TanStack Start Actually Changes",excerpt:"Server rendering isn't a performance checkbox â it collapses the wall between frontend and backend into one framework. Here's what SSR really buys you, why the client/server line stops being where you think it is, and where TanStack Start fits next to Next.js.",tags:["React","SSR","TanStack Start","Architecture","React Playbook"],date:"Apr 20, 2026",readTime:"13 min read",coverImage:"/blog/react-playbook-ssr-tanstack-start/cover.png",content:"\nFor most of this series I've drawn a clean line: React runs in the browser, the backend runs on a server, and the [contract between them](/blog/react-playbook-backend) is where the interesting decisions live. That line is useful. It's also about to dissolve.\n\nServer-side rendering is usually sold as a performance trick â render HTML on the server so the first paint is faster and Google can read your page. That's true, and it's the least interesting thing about it. The real change is architectural: SSR takes the wall between \"frontend\" and \"backend\" and turns it into a seam *inside a single framework*. Suddenly some of your React code runs on a server, some runs in a browser, and the hardest part is knowing which is which â and why it matters.\n\nSo this article isn't a list of SSR benefits. It's about what actually shifts in how you think when rendering moves to the server, and where TanStack Start lands in a world Next.js has owned for years.\n\n## What SSR Actually Buys You\n\nLet me be fair to the standard pitch first, because the benefits are real. Server rendering gives you a faster first contentful paint â the user sees content before a single kilobyte of JavaScript executes. It gives you SEO that actually works, because crawlers get real HTML instead of an empty `<div id=\"root\">`. And it gives you a better story on slow devices, because the server does the heavy lifting once instead of every phone doing it again.\n\nIf that were all SSR did, it'd be a performance optimization you turn on and forget. But turning it on changes the shape of your co
1de, and that's the part nobody warns you about until you hit it.\n\n## The Line Moves, and You Have to Know Where\n\nHere's the thing that trips everyone up, and it's worth slowing down on.\n\nIn a pure client-side app, all your code runs in one place: the browser. `window` exists. `localStorage` exists. Every component runs after the user's device has loaded it. Simple mental model.\n\nThe moment you add SSR, your components can run in *two* environments â once on the server to produce the initial HTML, and again (or partially) in the browser to make it interactive. And the server doesn't have `window`. It doesn't have `localStorage`. Code that assumed the browser now runs somewhere the browser's globals don't exist.\n\n```tsx\nfunction Sidebar() {\n // Runs on the server first â where 'window' is undefined.\n // This throws during SSR, and the error is confusing the first time.\n const collapsed = window.localStorage.getItem('sidebar-collapsed');\n return collapsed ? <Collapsed /> : <Expanded />;\n}\n```\n\nThat crashes on the server, and the first time it happens the error message sends you on a goose chase, because it's failing in a place you didn't know your component ran. This is the tax of SSR: you now have to know, for every piece of code, *where it executes*. Browser-only work â reading `localStorage`, touching the DOM, using `window` â has to be deferred to after hydration, usually in an effect. It's not hard once you have the mental model. It's brutal before you do.\n\n> With SSR, \"where does this run?\" stops being a trivia question and becomes the question. Half of SSR debugging is just answering it correctly.\n\nThat's the real cost, and no framework fully removes it â it's inherent to running the same code in two places. What a good framework does is make the boundary *visible* instead of something you discover by crashing.\n\n## Hydration: The Weird Middle Step\n\nThere's a moment between \"server sent HTML\" and \"app is interactive\" that deserves a name, because it's the source of the strangest SSR bugs: hydration.\n\nThe server sends fully-formed HTML so the user sees content immediately. But that HTML is inert â no event handlers, no state. Then the JavaScript loads and React \"hydrates\" it: it walks the server-rendered DOM and attaches all the interactivity, expecting the DOM it finds to match the DOM it would have rendered.\n\nWhen those two don't match â because you rendered a timestamp, or a random value, or something that read `window` â you get a hydration mismatch, and React complains. The fix is conceptual, not mechanical: anything that differs between server and client has to wait until after hydration. Once you internalize that server and client must agree on the first render, the whole class of hydration bugs stops being mysterious and starts being obvious.\n\n## Where TanStack Start Comes In\n\nEverything so far is true of any SSR framework. So why TanStack Start, in a world where Next.js is the default answer?\n\nBecause of what this series has already built. If you've been following along, you're using [TanStack Query](/blog/react-playbook-data-fetching) for server state and [TanStack Router](/blog/react-playbook-tanstack-router) for type-safe routing. TanStack Start is a full-stack framework built *on that same router* â so SSR, server functions, and data loading arrive as a natural extension of tools you already use, not a new paradigm you adopt wholesale.\n\nThe piece that matters most is **server functions** â typed functions you write once and call from the client, where the network boundary between them is handled for you.\n\n```ts\nimport { createServerFn } from '@tanstack/start';\n\nconst getPolicy = createServerFn('GET', async (id: number) => {\n // This body runs ONLY on the server â DB access is safe here.\n return db.policy.findUnique({ where: { id } });\n});\n\n// Called from a component; the type flows across the boundary,\n// and 'db' never ships to the browser.\nconst policy = await getPolicy(42);\n// ^? Policy â inferred end to end\n```\n\nIf that feels familiar, it should â it's the [tRPC idea from the backend article](/blog/react-playbook-backend), folded directly into the framework. You write a function that runs on the server, call it from the client with full type safety, and the framework draws the network line for you. The `db` import never reaches the browser. The type reaches everything. That coherence â one router, one type system, server and client speaking the same language â is the reason Start fits this series so naturally.\n\n## The Honest Comparison with Next.js\n\nI'm not going to pretend TanStack Start dethrones Next.js. It doesn't, and picking it over Next.js is a real trade-off, not a free upgrade.\n\nNext.js is mature, enormous, and battle-tested at every scale, with the deepest ecosystem in React and a hosting story that's effectively frictionless on Vercel. Its App Router and React Server Components are a genuinely different, powerful model. If your team wants the safest, most-documented, most-hireable-for choice, Next.js is it, and I'd never argue someone out of it on the merits.\n\nWhat TanStack Start offers is a different center of gravity. It's router-first and type-obsessed, and if your app is already built on TanStack's pieces, Start is the option where *everything agrees* â the same router types flow from your routes into your loaders into your server functions with
1no seams. Next.js asks you to adopt its model of the world; Start extends the model you already chose. For the kind of deeply-typed, client-rich application this series keeps building, that consistency is worth a lot. For a content site that wants RSC and edge rendering out of the box, Next.js is probably still the sharper tool.\n\nThat's the honest split: Next.js for maturity and reach, Start for coherence with a TanStack-native stack. Neither is a mistake.\n\n## The Real Takeaway\n\nStrip away the framework names and here's what SSR actually asks of you:\n\n- **Know where every piece of code runs.** Server or browser is now a question you answer constantly, not never.\n- **Defer browser-only work past hydration.** `window`, `localStorage`, the DOM â none of it exists during the server render.\n- **Make server and client agree on the first render.** Mismatches are the price of getting this wrong.\n- **Let the framework draw the boundary.** Server functions turn \"which side does this run on\" from a guess into a typed contract.\n\nI opened by saying the line between frontend and backend was about to dissolve, and this is what I meant. SSR doesn't move your code to the server â it makes \"the server\" and \"the browser\" two places your *same* code lives, and the skill it demands is holding both in your head at once. That's harder than
1it sounds and more valuable than it looks. The developers I trust most with a large React app aren't the ones who memorized a framework. They're the ones who always know where their code is running.\n\nWith rendering settled, the app needs somewhere to keep its data. The next article is about [databases for React developers](/blog/react-playbook-database-turso) â the kinds, the trade-offs, and why the modern serverless options change what \"add a database\" even means.\n\nIf you've fought a hydration mismatch that made no sense, or you're weighing Start against Next.js for a real project, I'd like to hear the specifics â those decisions are all about your exact stack, and that's where the interesting part lives.\n"},{id:"react-playbook-backend",title:"The Backend a React Developer Actually Needs to Understand",excerpt:"You don't have to write the server. But the shape of the contract between your React app and the backend â REST, tRPC, or a BFF â decides how much of your frontend is real work versus glue code. Here's the part of the backend that's actually yours.",tags:["React","Backend","tRPC","API Design","React Playbook"],date:"Apr 16, 2026",readTime:"13 min read",coverImage:"/blog/react-playbook-backend/cover.png",content:"\n\"That's a backend concern\" is one of the most expensive sentences a frontend developer can say.\n\nI understand the instinct. There's a clean line in most people's heads: the backend team owns the server, we own the browser, and the API is the wall between us. Someone hands you an endpoint, you fetch from it, you render. Not your problem what happens on the other side.\n\nExcept the shape of that wall decides how much of your job is real work versus glue code. I've spent weeks of my life writing frontend that existed only to paper over an awkward API â flattening nested responses, stitching three calls into one screen, re-deriving fields the server could have just sent. That's not frontend work. That's tax, paid in your codebase, because of a decision made on the other side of a wall you decided wasn't yours.\n\nSo this article isn't about writing servers. It's about the part of the backend that's genuinely yours as a React developer: the contract. How you talk to the server â REST, tRPC, or a BFF â shapes your frontend more than most of us are willing to admit.\n\n## The Contract Is the Product\n\nHere's the reframe that changed how I work: from the frontend, the backend *is* its API. You don't experience the database, the services, the queues. You experience the contract â the shape of the requests and responses. And a good contract makes the frontend small, while a bad one makes it a translation layer.\n\nThat's the lens for everything below. Not \"which technology is trendy,\" but \"which contract leaves the least incidental work in my React code.\" Because every gap between what the server sends and what the UI needs gets filled by *you*, in components that should be about rendering and instead are about reshaping data.\n\n## REST: The Default, and Its Real Cost\n\nREST is the lingua franca. Everything speaks it, every tool supports it, and for a lot of applications it's completely fine. When someone hands me a well-designed REST API â sensible resources, predictable shapes, the fields I need where I expect them â I have no complaints. It pairs perfectly with [TanStack Query](/blog/react-playbook-data-fetching), which was practically built for the REST request/response model.\n\nThe costs of REST are real but they're not \"REST is bad.\" They're two specific frictions.\n\nThe first is **over- and under-fetching**. An endpoint returns a fixed shape. Sometimes that's more than your screen needs, so you ship kilobytes you throw away. More often it's less than you need, so you make a second call, then a third, and now one screen is a waterfall of requests you have to orchestrate and cache.\n\nThe second, and the one that actually costs me time, is **types**. A REST response is JSON â untyped by nature. On the client it arrives as `any` unless you do something about it. Which means the boundary between \"typed React app\" and \"untyped network\" runs right through your data layer, and you spend effort re-establishing types the backend already knew.\n\n```ts\n// The gap, made concrete:\nconst res = await fetch('/api/policies/42');\nconst policy = await res.json(); // any â the types stop here\n\n// so you rebuild what the server already knew:\nconst policy = policySchema.parse(await res.json()); // Zod, by hand\n```\n\nThat `policySchema` is you, manually, re-declaring a shape the backend already has in its own code. It works â I do it, and validating at the boundary with Zod is genuinely good practice. But notice what it is: the same truth, written twice, on two sides of the wall, kept in sync by discipline. Hold that thought, because the next option deletes it.\n\n## tRPC: When You Own Both Sides\n\nIf your backend is TypeScript, and especially if it's the same repo or monorepo as your frontend, tRPC changes the entire equation. It gives you end-to-e
1nd type safety with no schema, no code generation, no manual sync.\n\nYou define a procedure on the server, and the client just *knows* its types â inputs, outputs, everything â inferred straight across the boundary.\n\n```ts\n// server: the procedure\nconst policyRouter = router({\n byId: publicProcedure\n .input(z.object({ id: z.number() }))\n .query(({ input }) => getPolicy(input.id)), // returns Policy\n});\n\n// client: fully typed, no codegen, no fetch wiring\nconst policy = await trpc.policy.byId.query({ id: 42 });\n// ^? Policy â inferred end to end\n```\n\nLook at what disappeared. No `policySchema` written twice. No `any` at the network boundary. Rename a field on the server and your React code fails to compile *before* you run it â the contract enforces itself. This is the same instinct the whole series keeps returning to: one source of truth, let the compiler carry it. tRPC just extends that line across the network, which is the one place REST couldn't.\n\nI'll be honest about the constraints, because they're firm. tRPC needs TypeScript on both ends, and it works best when the same team owns both. It's not for a public API consumed by clients you don't control, and it's not what you reach for when a separate backend team ships you REST or GraphQL. But for the shape this series keeps building â a TypeScript frontend and backend under one roof â it removes an entire category of busywork. When I control both sides, it's my default, for the same reason I pick every other tool in this series: it makes the frontend smaller.\n\n## GraphQL: The Over/Under-Fetching Answer\n\nGraphQL deserves a fair mention, because it targets REST's first friction directly. The client asks for exactly the fields it needs â no more, no less â in one request, even across what would have been several REST resources. For a complex UI assembling data from many places, that's a real answer to the waterfall problem, and the typed schema helps on the client too.\n\nI reach for it less than I used to, and it's a cost-benefit call, not a dismissal. GraphQL brings its own server, its own caching model, its own operational weight. On a large product with many clients and genuinely graph-shaped data, that weight pays for itself. On the mid-size TypeScript app this series targets, tRPC gets me most of the type-safety win for a fraction of the machinery. Right tool, right scale.\n\n## The BFF: A Backend That Works for the Frontend\n\nThere's one pattern worth knowing by name, because at some point you'll need it and it helps to know it has one: the **Backend for Frontend**.\n\nThe situation is familiar. You don't control the backend. It's a separate team, or several, exposing APIs designed for their convenience, not your screen's. Your dashboard needs data from three services in shapes that don't match what any of them return. The naive fix is to do all that stitching in React â three calls, merge, reshape, re-derive â and now your components are a data-integration layer wearing a UI costume.\n\nA BFF is a thin server layer that belongs to the frontend and sits between your React app and those messy upstream services. Its whole job is to talk to them and hand your UI exactly the shape it wants, in one call.\n\n```ts\n// BFF endpoint: one call for the frontend,\n// three upstream calls hidden behind it\nasync function getDashboard(userId: string) {\n const [profile, policies, claims] = await Promise.all([\n profileService.get(userId),\n policyService.listFor(userId),\n claimService.recentFor(userId),\n ]);\n return shapeForDashboard(profile, policies, claims); // exactly what the UI renders\n}\n```\n\nThe reason I like the BFF isn't just tidiness. It moves the stitching to where it belongs. Reshaping data, aggregating services, adapting an awkward upstream contract â that's backend work, and doing it in a Node BFF is cheaper, more testable, and more cacheable than doing it in React components. Your frontend goes back to rendering. And if you're already in a [TanStack Start](/blog/react-playbook-tanstack-router) or Next.js world, you have a natural place to put a BFF without standing up a whole separate service.\n\nThe through-line to the whole article is right here: whenever you find your React code reshaping data, ask whether that reshaping belongs on the server instead. Usually it does.\n\n## How I Actually Decide\n\nStrip it to the decision:\n\n- **A separate team ships you REST or GraphQL** â consume it with TanStack Query, validate at the boundary with Zod, and consider a BFF the moment your components start stitching.\n- **You own a TypeScript backend, same repo or org** â tRPC. End-to-end types, no manual sync, the frontend stays small.\n- **Complex graph-shaped data, many clients, real scale** â GraphQL earns its weight.\n- **You don't own the upstream and the shapes fight your UI** â a BFF, so the reshaping lives on the server where it's cheap.\n\nNone of this requires you to write a real backend. It requires you to have an opinion about the contract, and to push on it when it's making your frontend do work it shouldn't. That's the part that was always yours.\n\nI used to think staying out of backend decisions kept my work clean. It did the opposite â it let other people's convenience become my complexity. The most leverage I've found as a frontend developer isn't a faster component or a cleverer hook. It's a better contract, argued for early, that makes half the frontend code simply unnecessary. You don't have to build the server. You do have to care what it sends you.\n\nThe next article goes one layer deeper into that same server: [SSR and React with TanStack Start](/blog/react-playbook-ssr-tanstack-start) â where \"frontend\" and \"backend\" stop being two places and start being one framework, and what that does to everything we've built so far.\n\nIf you're staring at a React codebase that's mostly reshaping awkward API responses, that's usually a contract problem wearing a frontend costume â tell me what the API looks like and I'll tell you where I'd put the seam.\n"},{id:"react-playbook-authentication",title:"Authentication in React: Buy It, and Where the Line Really Is",excerpt:"Auth looks like a login form and turns out to be an architecture decision. Where do tokens live, who guards a route, what happens when the session dies mid-request? Here's why I buy auth instead of building it, and the parts that stay yours no matter what you buy.",tags:["React","Authentication","Security","Architecture","React Playbook"],date:"Apr 13, 2026",readTime:"13 min read",coverImage:"/blog/react-playbook-authentication/cover.png",content:"\nAuthentication always starts as a login form. Two fields and a button. You could build that in an afternoon, and that afternoon is exactly the trap.\n\nBecause the form isn't the work. The work is everything the form implies. Where does the token live once the server hands it back? How does every subsequent request prove who you are? What happens when the token expires halfway through a multi-step action the user has been filling out for ten minutes? Who decides that this route is off-limits to this person, and where does that decision live so it can't be bypassed? None of that is visible in the login form, and all of it is auth.\n\nSo this article isn't about building a login screen. It's about the decision underneath it â buy versus build â and about the parts that stay your responsibility no matter which way you go. Because there's a security floor here that a nice-looking form quietly hides, and getting it wrong doesn't throw an error. It just leaves a door open.\n\n## The Honest Default: Don't Build It\n\nI'll put my conclusion first, because I hold it strongly: for almost every application, you should not roll your own authentication.\n\nThis is not the usual \"don't reinvent the wheel\" reflex. Auth is a category where the cost of a subtle mistake is not a bug â it's a breach. Password hashing done slightly wrong. A token stored somewhere a script can read it. A session that doesn't actually invalidate on logout. A password-reset flow with a timing leak. These aren't exotic; they're the standard ways homegrown auth fails, and you usually find out about them from someone you'd rather not hear from.\n\nProvi
1ders like Auth0, Clerk, and Supabase Auth â and the framework-native options like NextAuth/Auth.js â exist because a lot of very focused people have already made those mistakes so you don't have to. They handle the hashing, the token lifecycle, the reset flows, the MFA, the OAuth dance with Google and GitHub. Buying that isn't laziness. It's routing your effort to where your product is actually different, which is almost never \"we hash passwords in a special way.\"\n\nSo my real advice is narrow: **use a provider, and spend your energy integrating it correctly** â because the integration is where the interesting decisions still live, and where you can still get it wrong.\n\n## Where Tokens Live Is the Whole Game\n\nHere's the decision that matters most on the client, and the one people get wrong most often: once you're authenticated, where does the credential live in the browser?\n\nThe tempting answer is `localStorage`. It's easy, it persists across tabs and reloads, and the token is right there when you need it.\n\nIt's also readable by any JavaScript running on your page. That's the problem. If an attacker gets a script onto your site â a compromised dependency, a bad ad, an XSS hole â `localStorage` hands them the token directly. This is the single most common serious mistake I see in hand-rolled React auth, and it looks completely fine until the day it isn't.\n\nThe safer model is an **httpOnly cookie**: the server sets it, the browser sends it automatically, and JavaScript literally cannot read it. That removes the entire class of \"a script stole the token\" attacks. The trade-off you take on in return is CSRF â because the cookie is sent automatically, you now need CSRF protection â but that's a well-understood problem with standard defenses, and it's a better problem to own than token theft.\n\n```ts\n// The shape of the good answer, conceptually:\n// - server sets an httpOnly, Secure, SameSite cookie on login\n// - the browser attaches it to every request automatically\n// - the React app never sees, stores, or touches the raw token\n// - CSRF protection guards the state-changing requests\n```\n\nNotice what this does to your React code: the app stops managing tokens at all. It doesn't read them, store them, or attach them to requests. That's the goal. The less your frontend knows about the raw credential, the fewer ways it can leak one. A good provider integration leans exactly this way â and this is precisely the kind of thing you get for free by buying instead of building.\n\n## Auth State Is Just State â Treat It That Way\n\nOn the client, \"am I logged in, and who am I?\" is a piece of state like any other. The mistake is treating it as special and inventing a bespoke system for it, when the tools this series has already chosen handle it cleanly.\n\nThe current user is server state â it lives on the server, you fetch it, it can go stale. That makes it a [TanStack Query](/blog/react-playbook-data-fetching) concern, not a hand-built context with manual loading flags.\n\n```tsx\nfunction useCurrentUser() {\n return useQuery({\n queryKey: ['currentUser'],\n queryFn: fetchCurrentUser,\n staleTime: 5 * 60 * 1000,\n retry: false,\n });\n}\n```\n\nNow the whole app has one honest source of truth for identity. Logging out is a cache invalidation. A 401 from any request can trigger a refetch that flips the user to logged-out everywhere at once. You didn't build an auth state machine â you reused the data layer you already have, and it behaves consistently because it's the same machinery as the rest of your data. That consistency is the point I keep coming back to in this series: identity isn't a special island, it's server state, so it uses the server-state tool.\n\n## Guarding Routes: A Convenience, Not a Wall\n\nThis is the part where a dangerous misconception hides, so I want to be blunt about it.\n\nProtecting a route on the client â redirecting away from `/dashboard` when there's no user â is a **UX feature, not a security control.** With [TanStack Router](/blog/react-playbook-tanstack-router) you can gate a route in its `beforeLoad`, and you should, because showing a logged-out person a broken dashboard is bad UX.\n\n```tsx\nexport const Route = createFileRoute('/dashboard')({\n beforeLoad: ({ context }) =>
1 {\n if (!context.auth.user) {\n throw redirect({ to: '/login' });\n }\n },\n});\n```\n\nBut understand exactly what that does and does not do. It stops a *browser* from *showing* a page. It does nothing to stop a *request* from *reaching your data*. Anyone can open dev tools, read your bundle, and call your API directly â the client-side guard is irrelevant to them.\n\n> Client-side route guards decide what the UI shows. The server decides what the user can actually access. Only one of those is security.\n\nThe real protection lives on the server: every endpoint validates the session and authorizes the specific action, every time, as if the client did not exist. The React route guard makes the app pleasant for honest users. The server check makes it safe from everyone else. If you only build one, build the server one â an app with server checks and no client guard is merely awkward; an app with client guards and no server checks is wide open.\n\n## The Part No Provider Handles: The Expiry Moment\n\nBuy the provider, store the token safely, guard your routes and your server â and there's still one moment that stays yours, because it's a UX problem, not a security one: the session dies while the user is in the middle of something.\n\nTokens expire. The user was filling out a long form (the exact painful form from the [forms article](/blog/react-playbook-form-libraries)), stepped away for coffee, came back, hit save â and the request comes back 401. What now?\n\nThis is where thoughtful apps separate from careless ones, and no library decides it for you. The naive answer is to hard-redirect to login and throw away everything they typed, which is how you make a user hate your product. The considered answer is a layer that catches the 401, attempts a silent token refresh if the provider supports it, retries the original request, and only if that genuinely fails preserves their work and prompts a re-login without discarding the form.\n\n```ts\n// Conceptual interceptor, not a full implementation:\n// 1. request returns 401\n// 2. attempt a silent refresh (if the provider offers refresh tokens)\n// 3. on success -> retry the original request, user notices nothing\n// 4. on failure -> keep their unsaved work, prompt a gentle re-login\n```\n\nThe provider hands you the refresh capability. What you do with it â how gracefully you handle the moment a session dies under a user's hands â is product design, and it's entirely yours. This is the through-line of the whole article: buying auth doesn't remove your judgment, it relocates it. You stop deciding how to hash a password and start deciding how it feels when a session expires. The second decision is the one your users actually experience.\n\n## Where the Line Really Is\n\nSo the buy-versus-build question has a cleaner answer than it looks:\n\n- **Hashing, token issuance, OAuth, MFA, password resets** â buy. This is the security floor, and the cost of building it wrong is a breach, not a bug.\n- **Where the token lives in the browser** â yours, and httpOnly cookies over `localStorage` is close to non-negotiable.\n- **Auth state in the app** â not special; it's server state, so it's a TanStack Query concern.\n- **Route guarding** â client-side for UX, server-side for security, and never confuse the two.\n- **The expiry-mid-action moment** â yours, and it's the difference between an app people trust and one they resent.\n\nI buy authentication, and I'd tell you to as well â not because integrating it is trivial, but because buying it moves the hard part to where it belongs. The interesting work was never the login form. It's the token storage, the guard boundary, the graceful death of a session. That's the part that shapes the architecture, and that's the part that's still yours after you've paid someone else for the rest.\n\nThe next article follows this thread straight down: [what a React developer actually needs to know about the backend](/blog/react-playbook-backend) â REST versus tRPC versus a BFF â because how you talk to your server decides more about your frontend than most of us admit.\n\nIf you've had a session expire under a user in a way that cost them real work â or if you're running `localStorage` tokens right now and want to talk through moving off them â reach out. That migration is one of the higher-leverage things you can do for an app's security, and I'm happy to compare notes.\
1n"},{id:"react-playbook-chart-libraries",title:"Charts in React: Who Renders the Pixels",excerpt:'"Just use a chart library" hides a real decision. Do you want charts handed to you, or do you want to control every pixel? That choice â and your data size â decides between Recharts, visx, and raw D3. Here\'s how I pick, and where charts actually get hard.',tags:["React","Data Visualization","Charts","Recharts","React Playbook"],date:"Apr 09, 2026",readTime:"12 min read",coverImage:"/blog/react-playbook-chart-libraries/cover.png",content:"\nThe chart looked perfect in the demo. Clean line, smooth animation, a tooltip that felt expensive. Then real data showed up â forty thousand points instead of the twenty I'd tested with â and the tab froze solid every time you hovered.\n\nThat's the moment you learn charts aren't a UI problem. They're a rendering problem wearing a UI costume.\n\n\"Just use a chart library\" is the advice everyone gives, and it's not wrong, exactly. It's just hiding the actual decision. There are a dozen chart libraries and they are not interchangeable. Picking one commits you to a rendering model, a customization ceiling, and a performance profile â and you usually discover all three at the worst possible time, which is after the design changes or the data grows.\n\nSo this article is about how I actually choose, and the question underneath the choice is the same one that decided the [UI library article](/blog/react-playbook-ui-libraries) and the [forms article](/blog/react-playbook-form-libraries): who owns the thing being rendered â the library, or me?\n\n## The Two Questions That Decide Everything\n\nBefore any library names, two questions do most of the sorting.\n\n**How much do you need to control the visuals?** A dashboard with standard bar and line charts is a different problem from a bespoke visualization no chart library has a preset for. The first wants defaults. The second wants primitives.\n\n**How big is your data, and how interactive is it?** A hundred points rendered as SVG is nothing. A hundred thousand points, hovering and zooming, is where SVG dies and you need Canvas or WebGL. This one question quietly eliminates half the options before you've read their docs.\n\nEverything below is really just these two axes: control vs done-for-you, and data size vs interactivity. Hold them in your head and the landscape stops looking like a dozen equivalent choices.\n\n## Recharts: The Sensible Default\n\nFor the majority of what people actually build â dashboards, admin panels, the CRM-shaped screens this series keeps returning to â Recharts is where I start. It's React-native and composable: charts are components, and you build them the way you build the rest of your UI.\n\n```tsx\nimport { LineChart, Line, XAxis, YAxis, Tooltip } from 'recharts';\n\nfunction RevenueChart({ data }: { data: MonthlyRevenue[] }) {\n return (\n <LineChart width={640} height={320} data={data}>\n <XAxis dataKey=\"month\" />\n <YAxis />\n <Tooltip />\n <Line type=\"monotone\" dataKey=\"revenue\" stroke=\"#22c55e\" />\n </LineChart>\n );\n}\n```\n\nLook at what that is: no imperative setup, no lifecycle wiring, no D3 selections. Axes are components. The line is a component. You compose a chart the way you compose a form or a page, and it fits a React codebase without friction. For standard chart types it's readable, quick, and easy to hand to another developer.\n\nThe ceiling is real, though, and worth naming. Recharts is great until your design asks for something outside its component vocabulary â a custom axis behavior, an unusual interaction, a shape it doesn't ship. Then you're either fighting its internals or reaching for something lower-level. For 80% of dashboards that ceiling is far above what you need. For the other 20%, you feel it early.\n\nThere are close siblings here â Nivo, Victory, react-chartjs-2 â and I won't pretend to rank them precisely. Nivo's defaults are beautiful and it has a Canvas escape hatch for larger datasets. Chart.js (via react-chartjs-2) renders to Canvas out of the box, which helps with volume but makes it feel less React-native. They cluster in the same place: high-level, done-for-you, great until the design goe
1s off-script.\n\n## visx: Primitives, Not Charts\n\nWhen the design *does* go off-script â when you're building a visualization rather than picking a chart â visx is what I reach for.\n\nvisx (from Airbnb) is a set of low-level primitives built on D3's math, wrapped so React owns the rendering. D3 does the scales, the shapes, the layout calculations; React draws the actual SVG. You don't get a `<BarChart>`. You get scales, axes, shapes, gradients â the pieces â and you assemble the exact chart you want.\n\n```tsx\nimport { scaleLinear } from '@visx/scale';\nimport { Bar } from '@visx/shape';\n\nconst xScale = scaleLinear({ domain: [0, maxValue], range: [0, width] });\n\nfunction CustomBars({ data }: { data: Point[] }) {\n return data.map((d) => (\n <Bar key={d.id} x={0} y={yScale(d.label)} width={xScale(d.value)} height={18} />\n ));\n}\n```\n\nThis is the same move as Radix and shadcn/ui from the earlier articles: the library gives you behavior and math, and hands the pixels back to you. The cost is honest â you write more, you think about scales and margins yourself, and there's no \"just drop in a chart.\" The payoff is that there's no ceiling. If you can describe it, you can draw it, and React stays in control of every element.\n\nI don't reach for visx for a revenue line chart. That would be building a table saw to cut one board. I reach for it when the visualization *is* the product feature, not a readout beside it.\n\n## Raw D3: Powerful, and at Odds with React\n\nD3 is the foundation under most of this, and you can absolutely use it directly. I mostly don't, and the reason is architectural, not a knock on D3.\n\nD3 wants to own the DOM. Its whole model is selecting elements and mutating them imperatively â enter, update, exit. React also wants to own the DOM, through its virtual model. Point them at the same nodes and you get two systems fighting over who's in charge, which shows up as subtle bugs where React re-renders away what D3 just drew, or vice versa.\n\nThe clean pattern is to split the roles: **let D3 do the math, let React do the rendering.** Use D3 for scales, layouts, and path generators â pure functions that take data and return numbers or SVG path strings â and let React turn those into elements. That's exactly what visx formalizes, which is why I'd rather use visx than wire that boundary by hand. If you do reach for raw D3 inside React, keep it to the calculation layer and let React render. The moment D3 starts calling `.append()` on nodes React manages, you've bought yourself a class of bugs that's genuinely hard to reason about.\n\n## The Thing Nobody Warns You About: SVG vs Canvas\n\nHere's the constraint that ambushed me in the opening story, and it's independent of which library you pick.\n\nMost chart libraries render to SVG, and SVG is wonderful â every point is a DOM node you can style, inspect, and attach handlers to. Until there are too many of them. Ten thousand DOM nodes is a slow page. Fifty thousand is a frozen one. The browser simply wasn't built to keep that many elements interactive.\n\nPast that threshold you need **Canvas** (one element, drawn imperatively, no per-point DOM) or **WebGL** for the truly massive. This changes what you're allowed to pick. If you know up front you're plotting a hundred thousand time-series points with hover and zoom, that fact chooses your library before any aesthetic preference does â you filter to the ones with a Canvas renderer and decide from there. Choosing a beautiful SVG library and *then* discovering the data size is how you end up rewriting the chart layer under deadline.\n\nSo I've learned to ask the data-size question first, not last. It's the least glamorous input and the most decisive one.\n\n## How I Actually Decide\n\nStrip it down and it's short:\n\n- **Standard dashboard charts, reasonable data size** â Recharts. React-native, composable, done. This is most cases.\n- **Beautiful defaults, occasionally large data** â Nivo, for its presets and Canvas option.\n- **A custom visualization that's a real product feature** â visx. Own the pixels, no ceiling.\n- **Genuinely huge datasets, heavy interaction** â a Canvas/WebGL renderer, and let data size lead the choice.\n- **R
1aw D3** â for the math, behind a React rendering layer. Rarely for the DOM directly.\n\nFor the Playbook, Recharts is the default and visx is the escape hatch â the same shape as every other choice in this series. Start with the composable, React-native option that agrees with the rest of the stack; drop to primitives only when the design demands it.\n\nBut I want to end where the real difficulty lives, because it isn't the library. The hard part of a chart is never the rendering â it's the data. Getting your series into the exact shape the chart wants, aggregating and bucketing correctly, choosing the visualization that answers the actual question instead of the one that looks impressive. A library renders whatever you hand it. It has no opinion on whether the chart is honest, or clear, or answering the right question. That part is yours, and no dependency will do it for you.\n\nData going in, data coming out â we've covered both. The next article turns to something every real app needs and everyone underestimates: [authentication](/blog/react-playbook-authentication). Rolling your own versus buying it, and where that decision quietly reshapes your whole architecture.\n\nIf you've hit the SVG performance wall, or fought D3 and React over the same DOM, I'd like to hear how it went â those are the war stories where the interesting trade-offs actually surface, and I'm always collecting the ones I haven't run into yet.\n"}
1,{id:"react-playbook-form-libraries",title:"Forms in React: Where TanStack Form Earns Its Place",excerpt:"Forms are where clean React apps quietly fall apart. Conditional fields, async validation, a submit button that has to know about everything â it adds up fast. Here's why I reach for TanStack Form, where it beats Formik and React Hook Form, and where a form library still can't save you.",tags:["React","Forms","TanStack Form","Validation","React Playbook"],date:"Apr 06, 2026",readTime:"13 min read",coverImage:"/blog/react-playbook-form-libraries/cover.png",content:"\nEvery codebase has one file everyone is a little afraid of. In the apps I've worked on, it's usually a form.\n\nNot a login form. Those are fine. I mean the real one â the checkout, the policy application, the onboarding flow with the field that only appears when you pick \"business account,\" which itself unlocks a tax ID field, which has to be validated against a server, but only after the user stops typing, and only if the country is one of four. That form. The one where every new requirement lands as another `if` somewhere in a 600-line component.\n\nForms are where clean React quietly falls apart. Everywhere else you can lean on good structure and it holds. Forms fight back. They mix local state, async work, cross-field logic, and user timing into one knot, and the naive version â a pile of `useState` and a submit handler that reads all of them â works right up until the requirements get real.\n\nSo this article is about the tool I reach for when a form stops being trivial, and, just as importantly, about the moment you actually need one. Because reaching for a form library too early is its own mistake.\n\n## When You Don't Need a Form Library\n\nLet me start against my own topic. A lot of forms don't need a library at all.\n\n```tsx\nfunction NewsletterSignup() {\n const [email, setEmail] = useState('');\n\n return (\n <form onSubmit={handleSubmit}>\n <input value={email} onChange={(e) => setEmail(e.target.value)} />\n <button type=\"submit\">Subscribe</button>\n </form>\n );\n}\n```\n\nThat's a form. It's fine. One field, no cross-field rules, validation you can do in a single line. Adding a library here buys you nothing but an import. I've seen people install a form framework for a search box, and it always reads like insecurity â reaching for structure the problem doesn't have yet.\n\nThe line I use is simple: **the moment fields start depending on each other, or validation has to talk to a server, or the same form has more than a handful of inputs, the hand-rolled version starts costing more than it saves.** Below that line, `useState` is the right answer. Above it, you want a real tool. Most interesting forms sit above it.\n\n## What Actually Makes Forms Hard\n\nBefore the library, it's worth naming what we're fighting, because the pain isn't \"typing out inputs.\" It's four things that stack:\n\n- **State that spreads.** Every field is state, but so is *touched*, *dirty*, *validating*, *error*, *submitting*. Multiply by twenty fields.\n- **Cross-field logic.** Field B is required only if field A has a value. Field C's options depend on field D. The rules form a little graph.\n- **Async validation.** \"Is this username taken?\" means a server round-trip, debounced, cancelable, with a pending state the UI has to show.\n- **Re-render cost.** The naive approach lifts everything to one state object, so one keystroke re-renders the entire form. On a big form you feel it.\n\nA good form library is really a tool for managing that specific mess. That's the lens I judge them through â not \"does it render inputs,\" but \"does it make those four things smaller.\"\n\n## Why TanStack Form\n\nThere are three names worth taking seriously: Formik, React Hook Form, and TanStack Form. I'll be fair to all of them, because none is a bad tool.\n\n**Formik** was the default for years. It's stable and widely known. But its model re-renders broadly by default, and its TypeScript story shows its age â types feel bolted on rather than designed in. On a large, dynamic form you end up working around it.\n\n**React Hook Form** is genuinely good, and if you're happy with it I won't try to talk you out of it. It's fast, because it leans on uncontrolled inputs and refs, and it's the right answer for a lot of teams. My hesitation is narrow: that ref-based, uncontrolled model gets awkward exactly where my forms get hard â deeply dynamic fields, tightly controlled components, cross-field reactivity. It optimizes for the common case in a way that can fight the uncommon one.\n\n**TanStack Form** is the one I default to in this series, and the reason is consistency more than superiority. It's type-safe to the core â your field names, values, and errors are all inferred, so renaming a field is a compiler problem, not a runtime surprise. It's granularly reactive: you subscribe to the exact slice of form state a component cares about, and only that component re-renders. And it's headless and framework-agnostic, which means it makes no styling decisions and hands control back to you â the same principle that decided the [UI library article](/blog/react-playbook-ui-libraries).\n\nIf you've followed this series, that reactivity model should feel familiar. It's the same instinct behind the [Reatom choice](/blog/react-playbook-reatom-state-management): fine-grained subscriptions instead of one big blob that re-renders on every change. A form is just reactive state with a lot of edges, and TanStack Form treats it that way.\n\n## The Shape of It\n\nHere's the same policy form, the kind this series keeps circling, wired with TanStack Form.\n\n```tsx\nimport { useForm } from '@tanstack/react-form';
1\n\nfunction PolicyApplication() {\n const form = useForm({\n defaultValues: {\n accountType: 'personal',\n holderName: '',\n taxId: '',\n },\n onSubmit: async ({ value }) => {\n await submitApplication(value);\n },\n });\n\n return (\n <form\n onSubmit={(e) => {\n e.preventDefault();\n form.handleSubmit();\n }}\n >\n <form.Field name=\"holderName\">\n {(field) => (\n <input\n value={field.state.value}\n onBlur={field.handleBlur}\n onChange={(e) => field.handleChange(e.target.value)}\n />\n )}\n </form.Field>\n </form>\n );\n}\n```\n\nThe verbosity is real, and I won't pretend otherwise â the render-prop `Field` component is more ceremony than React Hook Form's `register`. But look at what you get for it: `field.state.value` is typed from `defaultValues`, `name=\"holderName\"` is checked against the same shape, and everything inside that `Field` re-renders in isolation. The ceremony is buying type safety and surgical re-renders. On a small form that's a bad trade. On the 600-line one, it's the whole point.\n\n## Conditional Fields Without the Spaghetti\n\nThis is the part that sold me. Remember the \"business account unlocks a tax ID field\" requirement â the one that becomes an `if` in a naive form. Here it's just reading one field's value to decide whether to render another.\n\n```tsx\n<form.Subscribe selector={(state) => state.values.accountType}>\n {(accountType) =>\n accountType === 'business' ? (\n <form.Field\n name=\"taxId\"\n validators={{\n onChange: ({ value }) =>\n value.length === 0 ? 'Tax ID is required for business accounts' : undefined,\n }}\n >\n {(field) => (\n <input\n value={field.state.value}\n onChange={(e) => field.handleChange(e.target.value)}\n />\n )}\n </form.Field>\n ) : null\n }\n</form.Subscribe>\n```\n\nTwo things matter here. First, `form.Subscribe` with a `selector` means only *this* branch re-renders when `accountType` changes â the rest of the form doesn't flinch. Second, the validation rule lives *on the field that owns it*, not in a giant central schema that has to know about every conditional. The tax-ID rule exists only when the tax-ID field exists. When the field unmounts, its rule goes with it. That co-location is what keeps dynamic forms from rotting.\n\n## Async Validation That Doesn't Fight You\n\nThe \"is this taken?\" problem is where hand-rolled forms accumulate the most incidental complexity â debouncing, cancellation, a pending flag, a race condition you didn't see coming. TanStack Form has a slot for exactly this.\n\n```tsx\n<form.Field\n name=\"username\"\n validators={{\n onChangeAsyncDebounceMs: 400,\n onChangeAsync: async ({ value }) => {\n const taken = await checkUsernameTaken(value);\n return taken ? 'That username is already in use' : undefined;\n },\n }}\n>\n {(field) => (\n <>\n <input\n value={field.state.value}\n onChange={(e) => field.handleChange(e.target.value)}\n />\n {field.state.meta.isValidating && <span>Checkingâ¦</span>}\n {field.state.meta.errors[0] && <span>{field.state.meta.errors[0]}</span>}\n </>\n )}\n</form.Field>\n```\n\nThe library owns the parts that are easy to get subtly wrong: the debounce, the in-flight tracking via `isValidating`, cancelling a stale check when the value changes again. You own the one thing that's actually your business logic â the `checkUsernameTaken` call. That's the right division of labor. I don't want to write another debounced-async-with-cancellation dance by hand; I've written it enough times to know I'll get the edge case wrong.\n\n## Let the Schema Do the Talking\n\nInline validators are fine for one-off rules, but real forms usually already have a schema â and TanStack Form is happy to defer to it. Point a field's validator at a Zod schema and the types and the rules come from one source.\n\n```tsx\nimport { z } from 'zod';
1\n\nconst holderName = z.string().min(2, 'Name is too short');\n\n<form.Field name=\"holderName\" validators={{ onChange: holderName }}>\n {(field) => (/* ⦠*/)}\n</form.Field>\n```\n\nNow the validation rule and the TypeScript type live in the same place, and neither can drift from the other. This is the same instinct as everything else in the series: one source of truth, let the compiler enforce it. A form is often where a Zod schema you already have â for your API payload â gets a second job validating the UI. Reuse it. Don't write the shape twice.\n\n## Where a Form Library Still Can't Save You\n\nI want to end honestly, because it's easy to oversell this. A form library manages state, validation, and re-renders. It does not manage *product complexity*, and most painful forms are painful because the product is.\n\nMulti-step wizards with a back button that has to preserve state. Fields whose valid options depend on an API call that depends on another field. Save-as-draft that has to serialize a half-finished form. A submit that's really five API calls with partial-failure handling. TanStack Form gives you a clean foundation for all of it â but the wizard's flow, the dependency graph, the draft format, the failure strategy are yours to design. The library keeps the state honest. It doesn't tell you what the state should be.\n\nThat's the realistic version. The right tool removes the incidental pain â the re-renders, the type drift, the debounce dance â so that what's left is the *essential* pain, the actual product logic. That's the most any library can do, and it's a lot. A form that only hurts where the problem is genuinely hard is a form you can live with.\n\nFor the Playbook, TanStack Form is the choice, for the same reason shadcn/ui and Reatom were: it's headless, type-safe, and granularly reactive, so it points the same direction as everything else we've built. Coherence over any single feature.\n\nForms handle the data going *in*. The next article is about making sense of data coming *out* â [visualization and charting in React](/blog/react-playbook-chart-libraries), and why \"just use a chart library\" is a much bigger decision than it sounds.\n\nIf you've got a form in your codebase that everyone tiptoes around, I'd genuinely like to hear what makes it hard â the specific requirement that broke the clean version. That's usually where the interesting design problems live, and I'm always looking for the ones I haven't hit yet.\n"}
1,{id:"react-playbook-ui-libraries",title:"React UI Libraries: Who Owns the Styling Decides Everything",excerpt:"Picking a component library feels like picking buttons and modals. It isn't. You're deciding who controls your styling â you or the library â and that single choice shapes how much you'll fight it for years. Here's how I weigh MUI, Ant Design, Radix, and shadcn/ui.",tags:["React","UI Libraries","shadcn/ui","Radix","React Playbook"],date:"Apr 02, 2026",readTime:"13 min read",coverImage:"/blog/react-playbook-ui-libraries/cover.png",content:"\nThe demo always goes well. That's the trap.\n\nYou're evaluating a component library. You drop in their date picker, their data table, their modal. In twenty minutes you have something that looks polished, and the decision feels made. Ship it.\n\nThe problems don't show up in the demo. They show up eight months later, when a designer hands you a spec that doesn't match the library's defaults, and you discover that changing one border radius means fighting three layers of the library's own styling system. That's when you learn what you actually signed up for.\n\nSo I've stopped evaluating UI libraries by how good the demo looks. The question I ask now is different, and it's uncomfortable:\n\n> When my design diverges from the library's defaults â and it will â who wins, me or the library?\n\nThat question splits the entire landscape cleanly. And it connects straight back to the [styling decision from the previous article](/blog/react-playbook-css-styling): once you've committed to Tailwind and a token system, a component library that brings its *own* styling engine isn't a neat addition. It's a second opinion arguing with your first one.\n\nLet me walk through the four I actually consider â MUI, Ant Design, Radix, and shadcn/ui â grouped not by popularity, but by who holds the pen.\n\n## Two Families, Not Four Libraries\n\nBefore the individual names, the split that matters:\n\n- **Batteries-included, styled libraries** â MUI, Ant Design. They give you finished, good-looking components *and* the styling system that drives them. You adopt their look and their theming model.\n- **Headless / you-own-the-styles** â Radix, shadcn/ui. They give you behavior and accessibility, and hand the styling back to you.\n\nAlmost everything else follows from which family you pick. The first family gets you to \"looks done\" fastest. The second gets you to \"looks like *ours*\" without a fight. Neither is wrong. They're answers to different questions.\n\n## MUI: Powerful, Complete, and Opinionated\n\nMUI (Material UI) is the library I'd hand someone who needs a lot of surface area, fast, and doesn't have a strong custom design language to protect.\n\n```tsx\nimport { Button, TextField } from '@mui/material';\n\nfunction PolicyForm() {\n return (\n <form>\n <TextField label=\"Holder name\" variant=\"outlined\" />\n <Button variant=\"contained\">Save</Button>\n </form>\n );\n}\n```\n\nWhat's genuinely great: the component coverage is enormous. Data grids, date pickers, autocomplete, everything. The accessibility is solid. For an internal tool or a dashboard where \"clean and consistent\" beats \"uniquely ours,\" MUI gets you there remarkably fast.\n\nHere's my reservation, and it's the same one that decides most of this article. MUI brings its own styling engine â historically Emotion, a runtime CSS-in-JS system. You saw in the last article why I've cooled on runtime CSS-in-JS: the cost moved, and it sits awkwardly with Server Components. MUI inherits that tension. And it carries a point of view â Material Design â that you either want, or spend a long time theming your way out of. Overriding MUI deeply means learning MUI's override system, which is its own body of knowledge.\n\nMUI isn't a bad choice. It's a *committing* choice. You're adopting a design language and a styling runtime along with the components.\n\n## Ant Design: Enterprise Density Out of the Box\n\nAnt Design is MUI's closest sibling in philosophy, aimed at a slightly different target: dense, data-heavy enterprise applications.\n\nIf you're building an admin panel with complex tables, filters, and forms â the CRM-shaped software this series keeps circling back to â Ant's defaults are tuned for exactly that. The tables alone save weeks.\n\nThe trade-off is the same shape as MUI's, just with a different accent. You're adopting Ant's visual language, which is strong and distinctly *Ant* â apps built with it tend to look like they're built with it. Deep customization means working within Ant's theming system, and stepping outside its defaults gets progressively harder the further you go. Honestly, if your product needs to look like your brand rather than like an Ant demo, this is the friction you'll feel most.\n\nBoth MUI and Ant answer the question \"how do I get a lot of good components quickly?\" Well. The next two answer a different question: \"how do I keep control of how everything looks?\"\n\n## Radix: Behavior Without the Costume\n\nRadix Primitives took the part that's genuinely hard â accessible, correct interactive behavior â and shipped *only* that. No visual design at all.\n\n```tsx\nimport * as Dialog from '@radix-ui/react-dialog';\n\nfunction ConfirmDialog() {\n return (\n <Dialog.Root>\n <Dialog.Trigger>Delete policy</Dialog.Trigger>\n <Dialog.Portal>\n <Dialog.Overlay className=\"fixed inset-0 bg-black/40\" />\n <Dialog.Content className=\"fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 roun
1ded-lg bg-white p-6\">\n <Dialog.Title className=\"text-sm font-semibold\">Are you sure?</Dialog.Title>\n </Dialog.Content>\n </Dialog.Portal>\n </Dialog.Root>\n );\n}\n```\n\nLook at what Radix gives you and what it doesn't. It gives you the dialog's focus trapping, escape-to-close, scroll locking, ARIA wiring, portal handling â all the things that are tedious and easy to get subtly wrong. It gives you *no* appearance. Every `className` there is mine, and it's Tailwind, and Radix doesn't care.\n\nThat's the whole point. Radix is what you reach for when accessibility matters and you refuse to inherit someone else's look. The cost is honest: you build the visual layer yourself. For a one-off that's work. Which is exactly the gap the last option fills.\n\n## shadcn/ui: The One That Fits This Series\n\nshadcn/ui isn't really a library, and that's the clever part. It's a collection of components built *on* Radix, styled *with* Tailwind, that you copy into your own codebase.\n\nYou don't `npm install` a black box. You run a command, and the component's source lands in your project as your code:\n\n```bash\nnpx shadcn@latest add dialog\n```\n\nNow `components/ui/dialog.tsx` is *yours*. Radix underneath for behavior, Tailwind classes for the look, and every line editable because it lives in your repo. There's no override system to learn â if you want a different border radius, you change the border radius, because it's just a file you own.\n\nI'll be direct about why this fits the Playbook so well. We chose Tailwind and a token system in the last article. shadcn/ui is built on exactly that foundation â its components read from your Tailwind tokens, so they inherit your design system instead of imposing one. Radix gives the behavior I'd otherwise have to build by hand. And because the code is mine, the library can never become the thing I'm fighting. There's no black box to fight *with*.\n\nThe trade-off is real and worth stating: you own more code. Updates aren't an `npm update` â you re-pull components or merge changes by hand. For some teams that ownership is a burden. For a team that wants its product to look like itself and never wants to be trapped by a vendor's styling decisions, it's the feature, not the cost.\n\n## The Decision, Honestly\n\nStrip away the popularity contests and it comes down to that one question â who owns the styling:\n\n- **You need enormous component coverage fast, and a strong custom look isn't the priority** â MUI. Adopt its language on purpose.\n- **You're building dense, data-heavy enterprise screens** â Ant Design. Its defaults are tuned for exactly that.\n- **You need accessible behavior but insist on owning every pixel** â Radix primitives, styled yourself.\n- **You want owned, Tailwind-native components with Radix behavior underneath** â shadcn/ui. This is my default for the kind of app this series builds.\n\nNotice that the \"right\" answer flips entirely based on one thing: how much your product needs to look like *yours* versus how fast it needs to look *done*. There's no library that wins both, and anyone selling you one hasn't hit month eight yet.\n\nFor the Playbook, I'll build on shadcn/ui, because it's the choice that agrees with every decision we've made so far instead of arguing with one of them. That coherence â styling, tokens, components all pointing the same way â matters more than any single component's polish.\n\nComponents give you the pieces. The next article is about the hardest piece to get right: forms. We'll build real ones with [TanStack Form](/blog/react-playbook-form-libraries), the kind with conditional fields, async validation, and the messy requirements that make forms the place UIs go to die.\n\nIf you're weighing a component library right now, do one thing before you commit: try to restyle its busiest component to look nothing like the default. However that fight goes is your real answer. And if you've made peace with MUI or Ant at scale and found the customization ceiling higher than I'm describing, tell me â I'd genuinely like to know where the line actually sits.\n"},{id:"react-playbook-css-styling",title:"Styling React in 2026: How I Actually Choose",excerpt:"Styling is the most argued-about, least reasoned-about decision in a React app. The real question isn't which library looks
1nicest in a demo â it's which one survives a growing codebase. Here's how I weigh Tailwind, CSS Modules, CSS-in-JS, and zero-runtime options, and where each one earns its place.",tags:["React","CSS","Tailwind","Styling","React Playbook"],date:"Mar 30, 2026",readTime:"13 min read",content:"\nAsk ten React developers how to style a component and you'll start a fight.\n\nNot a discussion. A fight. Tailwind people and CSS-in-JS people don't disagree about syntax â they disagree about identity. Somewhere along the way, \"how do we apply styles\" turned into a values debate, and the actual engineering question got buried under it.\n\nHere's the thing that bugs me about that debate: almost none of it is about the question that matters. It's about which approach looks cleanest in a ten-line demo. But nobody styles a ten-line demo for a living. You style an application that grows, gets handed to new people, adds a design system halfway through, and has to survive a framework upgrade.\n\nSo the question I actually ask isn't \"which one is prettiest.\" It's:\n\n> When this codebase triples in size and I wasn't the one who wrote half of it, which styling approach still makes sense?\n\nThat reframes everything. Let me walk through the real options the way I weigh them â Tailwind, CSS Modules, CSS-in-JS, and the zero-runtime newcomers â and where each one genuinely fits.\n\n## First, The Question Under The Question\n\nEvery styling approach is really answering three separate questions, and people conflate them:\n\n1. **Where does the style live?**
1Same file as the component, a co-located file, or a global sheet.\n2. **When is the style computed?** At build time, or at runtime in the browser.\n3. **How do you stay consistent?** Ad-hoc values, or tokens and constraints.\n\nMost \"Tailwind vs styled-components\" arguments are really arguments about question 2 without anyone saying so. Runtime cost is the thing that quietly decides a lot of this in 2026, especially with React Server Components in the picture. Keep those three questions in mind â they'll do more for you than any hot take.\n\n## Tailwind: The Default I Keep Coming Back To\n\nI'll be honest about my bias up front: Tailwind is my default. Not because it's fashionable, and not without reservations. Because of what it does to a *team*, not to a single component.\n\n```tsx\nfunction PolicyCard({ policy }: { policy: Policy }) {\n return (\n <article className=\"rounded-lg border border-slate-200 p-4 hover:border-slate-300\">\n <h3 className=\"text-sm font-semibold text-slate-900\">{policy.name}</h3>\n <p className=\"mt-1 text-xs text-slate-500\">{policy.status}</p>\n </article>\n );\n}\n```\n\nThe first reaction most people have is \"that's ugly, the markup is noisy.\" Fair. I had the same reaction. Then I worked in codebases where Tailwind had been the standard for a year, and the noise stopped mattering. Here's what I noticed instead:\n\n- **Nobody invents new spacing values.** `p-4` is `p-4` everywhere. The design constraints are baked into the class names, so drift is hard.\n- **Deleting a component deletes its styles.** No orphaned CSS rotting in a global file because everyone's afraid to remove it.\n- **No naming tax.** I don't spend energy inventing `.policy-card__status--pending`. The cognitive cost of good CSS class names is real, and Tailwind just removes it.\n- **It compiles away.** The output is a static stylesheet. Zero runtime style computation, which means it plays nicely with Server Components.\n\nThe trade-offs are real too, and I won't pretend otherwise. The markup is genuinely harder to scan at first. Long class strings need something like `clsx` or `tailwind-merge` once conditions appear. And onboarding someone who's never used it costs a day or two of \"why is everything a class name.\"\n\nThe reservation I take most seriously: Tailwind is weak when a style is genuinely *dynamic* â driven by a value only known at runtime, like a chart bar's height from data. You don't want `h-[347px]` generated on the fly. For that, an inline style or a CSS variable is the right tool, and mixing them in is fine. Tailwind for the static 95%, a CSS variable for the dynamic 5%.\n\nThat's my honest position: Tailwind wins on team-scale maintainability, and that's the axis I care about most. But \"it wins for me\" is not \"it wins for everyone,\" so let me give the alternatives a real hearing.\n\n## CSS Modules: The Boring, Correct Answer\n\nIf Tailwind's class soup is a dealbreaker for you, CSS Modules is the option I respect the most.\n\n```tsx\nimport styles from './PolicyCard.module.css';\n\nfunction PolicyCard({ policy }: { policy: Policy }) {\n return (\n <article className={styles.card}>\n <h3 className={styles.title}>{policy.name}</h3>\n <p className={styles.status}>{policy.status}</p>\n </article>\n );\n}\n```\n\n```css\n/* PolicyCard.module.css */\n.card {\n border-radius: 0.5rem;\n border: 1px solid var(--color-border);\n padding: 1rem;\n}\n```\n\nWhat I like: it's just CSS. No new mental model, no runtime, locally scoped class names so you can't leak styles across the app. It compiles to a static stylesheet, so like Tailwind it's Server Components-friendly. If a team has strong CSS skills and hates utility classes, this is not a compromise â it's a genuinely solid choice.\n\nWhat pushes me away from it at scale: it doesn't enforce consistency on its own. Nothing stops one file from using `padding: 16px` and another using `padding: 1rem` and a third using `padding: 14px` because someone eyeballed it. You get scoping for free, but you have to build the discipline â the tokens, the shared variables â yourself. Tailwind ships that discipline in the box. That's the whole difference, and for some teams CSS Modules plus a well-guarded token file is exactly right.\n\n## CSS-in-JS: Why I'm Walking Away From It\n\nThis is the one where I've changed my mind, so I'll say it plainly.\n\nI used to reach for styled-components and Emotion without a second thought. The developer experience is lovely: styles co-located with the component, full access to props, dynamic styling that feels natural.\n\n```tsx\nconst Card = styled.article<{ $pending: boolean }>`\n border-radius: 0.5rem;\n padding: 1rem;\n border: 1px solid ${(p) => (p.$pending ? '#f59e0b' : '#e2e8f0')};\n`;\n```\n\nThat reads great. So why am I walking away?\n\nBecause the cost moved. Runtime CSS-in-JS computes styles in the browser, on render, and serializes them into the DOM as the app runs. For years that cost was acceptable. Then React Server Components arrived, and runtime CSS-in-JS sits awkwardly with them â these libraries lean on runtime context and client-side execution, which is exactly what Server Components are trying to avoid. The React team's own guid
1ance nudged people toward compile-time styling for a reason.\n\nI don't say this to bury a whole category â if you're on a heavily client-rendered app and the DX makes your team faster, runtime CSS-in-JS is not a crime. But for new work in 2026, especially anything touching Server Components, I no longer start here. The direction of the ecosystem is away from runtime style computation, and I'd rather not build on the side that's swimming against it.\n\nWhich leads directly to the interesting middle ground.\n\n## Zero-Runtime CSS-in-JS: The Best of Both, Mostly\n\nTools like vanilla-extract keep the part of CSS-in-JS people actually love â writing styles in TypeScript, with type-safe tokens â while throwing away the part that hurts: the runtime.\n\n```ts\n// PolicyCard.css.ts\nimport { style } from '@vanilla-extract/css';\nimport { tokens } from '../theme.css';\n\nexport const card = style({\n borderRadius: tokens.radius.md,\n border: `1px solid ${tokens.color.border}`,\n padding: tokens.space.md,\n});\n```\n\nYou author in TypeScript, get autocomplete and type-checking on your design tokens, and it all compiles to a static stylesheet at build time. Zero runtime. Server Components-friendly. Honestly, this is the most intellectually satisfying option on the list â it fixes the exact thing that made me leave runtime CSS-in-JS.\n\nSo why isn't it my default? Two reasons, and neither is technical. The ecosystem and hiring pool around Tailwind are enormous, and the token-based type safety that vanilla-extract gives you, Tailwind approximates well enough through its config. If your team already thinks in TypeScript tokens and wants that safety enforced by the compiler, vanilla-extract is a genuinely excellent pick â arguably the \"correct\" one on paper. I just find Tailwind gets a team to the same maintainability with less setup.\n\n## The Thread That Ties It Together: Tokens\n\nNotice what quietly showed up in almost every example above â `var(--color-border)`, `tokens.space.md`, Tailwind's `slate-200`. That's the part that actually matters, and it's independent of which library you pick.\n\n> The styling library is a delivery mechanism. Design tokens are the actual system.\n\nWhatever you choose, the thing that keeps a large app coherent is a single source of truth for color, spacing, radius, and typography â and the discipline to never bypass it with a magic number. Tailwind gives you tokens through its config. Vanilla-extract gives you tokens as typed objects. CSS Modules gives you tokens through CSS custom properties. The delivery differs; the principle doesn't. If you get tokens right, you can survive changing your styling library later. If you get them wrong, no library saves you.\n\n## So What Do I Actually Pick?\n\nStripped of tribalism, here's my honest decision path:\n\n- **New app, normal team, wants to move fast and stay consistent** â Tailwind. It ships the constraints in the box.\n- **Team with strong CSS culture that dislikes utility classes** â CSS Modules plus a guarded token file. Boring and correct.\n- **Team that lives in TypeScript and wants compiler-enforced tokens** â vanilla-extract. The most \"principled\" pick.\n- **Heavily client-rendered app where runtime DX wins** â runtime CSS-in-JS is still defensible, eyes open about Server Components.\n\nThere's no universally right answer, and anyone who tells you there is hasn't worked in enough different codebases. For this series I'll use Tailwind, because it fits the kind of app we're building and it pairs cleanly with what comes next.\n\nBecause styling rarely lives alone. The moment you have a real app, you reach for a component library â and how it's styled decides whether you can theme it, override it, or fight it. That's the next article: [choosing a React UI library](/blog/react-playbook-ui-libraries), and why the styling decision you just made quietly constrains that choice.\n\nIf your team is mid-argument about this right now, try changing the question. Stop asking which one is best and start asking which one your codebase can live with in two years. And if you've landed somewhere I'd disagree with â especially if you're still happily on runtime CSS-in-JS â I genuinely want to hear the case. I've changed my mind on this before.\n"},{id:"react-playbook-immutable-data-structures",title:"Immutable Data in React: The Rule I Stopped Arguing About",excerpt:"A module I was told not to over-engineer grew into an entire application. Most of what let it survive was boring â and one of the most boring rules was never mutating state in place. Here's how immutability actually works in React, when I reach for Immer, and why I skip Immutable.js.",tags:["React","Immutability","Reatom","Architecture","React Playbook"],date:"Mar 27, 2026",readTime:"11 min read",coverImage:"/blog/react-playbook-immutable-data-structures/cover.png",content:"\nThe company had a legacy PHP application, and we were not brought in to rebuild it. We were brought in to add one module.\n\nOne module. A self
1-contained piece of the product that a couple of teams needed. Nobody expected it to matter much beyond that.\n\nSo when I pushed for structure â a real data model, immutable updates, clear boundaries â there was pushback. Fair pushback. \"It's one module. Why the ceremony? Why can't we just mutate the thing and move on?\"\n\nI understood the argument. On a small, disposable module, discipline can look like pure overhead. You feel every extra rule slowing you down long before it pays you back.\n\nThen the module did the thing small modules occasionally do. It got good. It got used. Other teams started building against it. Over a couple of years it quietly stopped being a module and became the main application, with almost everything bolted onto the shape we set at the start. I still talk to the people there. They are still building on that foundation today.\n\nI'm not telling this to claim we were geniuses. We weren't. Most of what held up under that growth was boring. And one of the most boring rules of all was this: we never mutated state in place.\n\nThat's what this article is about. Immutable data in React â not the purity lecture, but the practical discipline. Where it bites you if you skip it, when the native approach stops being pleasant, and how it connects to the reactive model from the [Reatom article](/blog/react-playbook-reatom-state-management) earlier in this series.\n\nIf you know JavaScript, you already know how to copy an object. That's not the hard part. The hard part is understanding *why* React, memoization, and reactive stores punish you the moment you forget.\n\n## What Mutation Actually Breaks\n\nReact doesn't deeply compare your state. It compares references.\n\nThat single sentence is the whole thing. Everything else is a consequence of it.\n\nHere's a bug I've watched people write, debug for twenty minutes, and never fully explain afterward:\n\n```tsx\nconst [draft, setDraft] = useState<PolicyDraft>(initialDraft);\n\nfunction addBeneficiary(beneficiary: BeneficiaryDraft) {\n draft.beneficiaries.push(beneficiary); // mutation\n setDraft(draft); // same reference\n}\n```\n\nYou added a beneficiary. The data changed. The screen didn't.\n\nReact ran `Object.is(previousDraft, nextDraft)`, saw the same object reference, and decided nothing changed. Your `push` mutated the array in place, so the outer `draft` object is still the exact same reference it was before. As far as React is concerned, you called `setDraft` with the value it already had.\n\nThe fix isn't a trick. It's a new reference:\n\n```tsx\nfunction addBeneficiary(beneficiary: BeneficiaryDraft) {\n setDraft({\n ...draft,\n beneficiaries: [...draft.beneficiaries, beneficiary],\n });\n}\n```\n\nNew object, new array. React compares references, sees a difference, re-renders.\n\nThis is the React nuance the JS-fundamentals tutorials skip. Immutability in React isn't about being functionally pure for its own sake. It's that the entire rendering and memoization model is built on the assumption that *a change produces a new reference*. Break that assumption and everything downstream â `React.memo`, `useMemo`, dependency arrays, reactive stores â quietly stops working. Not with an error. With silence.\n\n> Mutation doesn't crash. It just makes your UI lie about the data.\n\n## The Rule: A New Reference On Every Change\n\nThe rule is boring, and boring is the point:\n\n**Never change data in place. Produce a new value.**\n\nFor flat, shallow state, the native tools are enough, and I reach for them first.\n\nAdd an item:\n\n```ts\nconst next = [...beneficiaries, newBeneficiary];\n```\n\nRemove by id:\n\n```ts\nconst next = beneficiaries.filter((b) => b.id !== removedId);\n```\n\nUpdate one item by id â the operation people get wrong most often:\n\n```ts\nconst next = beneficiaries.map((b) =>\n b.id === targetId ? { ...b, share: newShare } : b,\n);\n```\n\nNotice what `map` does here. Every item that didn't change keeps its original reference. Only the one you touched becomes a new object. That's not an accident â it's exactly what you want. Components rendering the untouched rows can skip re-rendering, because their props are referentially identical.\n\nUpdate a field on an object:\n\n```ts\nconst next = { ...draft, holderName: 'Updated Name' };\n```\n\nNone of this is clever. That's the appeal. Anyone reading it can see what changed and what stayed the same.\n\n## Where Native Updates Get Painful\n\nThen the state gets nested, and the spreads start to hurt.\n\nSay the policy draft looks like this:\n\n```ts\ninterface PolicyDraft {\n holderName: string;\n sections: {\n coverage: {\n type: 'standard' | 'premium';\n limits: {\n medical: number;\n liability: number;\n };\n };\n };\n}\n```\n\nNow update `limits.medical` immutably with native syntax:\n\n```ts\nconst next = {\n ...draft,\n sections: {\n ...draft.sections,\n coverage: {\n ...draft.sections.coverage,\n limits: {\n ...draft.sections.coverage.limits,\n medical: newValue,\n },\n },\n },\n};\n```\n\nTechnically correct. Practically miserable. You changed one number and wrote a five-level spread pyramid to do it. Worse, in a code review nobody can see at a glance *what actually changed* â the signal is buried under structural boilerplate. And every one of those spread levels is a place to make a typo that silently drops a branch of state.\n\nThis is the moment people conclude \"immutability is exhausting\" and start looking for shortcuts. Some of those shortcuts are good. One of them is a mistake.\n\n## Immer: The Escape Hatch for Deep Updates\n\nWhen updates get deep, I reach for [Immer](https://immerjs.github.io/immer/).\n\nImmer lets you write code that *looks* like mutation and hands you an immutable result:\n\n```ts\nimport { produce } from 'immer';\n\nconst next = produce(draft, (d) => {\n d.sections.coverage.limits.medical = newValue;\n});\n```\n\nThat's the same nested update as above. One line. You mutate a temporary draft object; Immer records the changes and produces a new immutable value, reusing every branch you didn't touch. That last part matters: the `holder
1Name` string and the untouched `liability` limit keep their references, so referential-equality checks and memoization still work. You get the ergonomics of mutation with the guarantees of immutability.\n\nMy rule of thumb is narrow on purpose:\n\n- **Flat or shallow state â native spreads.** Don't pull in Immer to change one field on a flat object. It's noise.\n- **Deep or awkwardly nested updates â Immer.** The moment the spread pyramid appears, `produce` earns its place.\n\nImmer isn't free. It wraps your drafts in proxies and freezes output in development, which is a real runtime cost. For application state â forms, drafts, workflow models â that cost is invisible. I would not reach for it inside a hot render loop or a tight animation frame. Right tool, right layer.\n\n## Why I Skip Immutable.js\n\nEvery time this topic comes up, someone asks about [Immutable.js](https://immutable-js.com/). It gives you `Map`, `List`, and structural sharing baked in, so surely it's the \"serious\" choice?\n\nHonestly, no. Not in a modern React codebase, and not for me.\n\nThe problem isn't the data structures. They're well made. The problem is that Immutable.js introduces *a parallel type system* that doesn't speak plain JavaScript:\n\n```ts\nconst draft = Map({ holderName: 'Ann', beneficiaries: List() });\n\nconst name = draft.get('holderName'); // not draft.holderName\nconst next = draft.set('holderName', 'Bob'); // not { ...draft }\nconst plain = draft.toJS(); // needed at every boundary\n```\n\nThat `toJS()` at the boundary is the tell. Your API returns plain objects. TanStack Query caches plain objects. TanStack Form, most component libraries, your logging, your TypeScript types â all assume plain objects. So you spend your life converting in and out, and every conversion is a place for bugs and lost type information. The TypeScript ergonomics are genuinely worse than working with plain interfaces.\n\nNative immutable updates plus Immer for the deep cases cover everything Immutable.js was solving, without forcing the entire codebase to adopt a second notion of what \"an object\" is. I stopped considering it years ago and haven't missed it once.\n\n## Immutability and Reatom\n\nThis is where the previous article and this one meet.\n\nIn the [Reatom piece](/blog/react-playbook-reatom-state-management), the whole model was a graph: small atoms, derived atoms, actions as transitions. That graph runs on the exact same assumption React does â **a change is a new reference.** Reatom decides what to recompute and what to re-render by comparing atom values by reference. Mutate an atom's value in place and you reproduce the React bug one layer down: derived atoms don't recompute, subscribed components don't update.\n\nSo the wrong version:\n\n```ts\nexport const addBeneficiary = action((ctx, beneficiary: BeneficiaryDraft) => {\n const draft = ctx.get(policyDraftAtom);\n draft.beneficiaries.push(beneficiary); // mutation\n policyDraftAtom(ctx, draft); // same reference â nothing reacts\n}, 'addBeneficiary');\n```\n\nAnd the right one:\n\n```ts\nexport const addBeneficiary = action((ctx, beneficiary: BeneficiaryDraft) => {\n const draft = ctx.get(policyDraftAtom);\n\n policyDraftAtom(ctx, {\n ...draft,\n beneficiaries: [...draft.beneficiaries, beneficiary],\n });\n}, 'addBeneficiary');\n```\n\nAnd when the transition is deep, Immer lives comfortably inside an action:\n\n```ts\nimport { produce } from 'immer';\n\nexport const setMedicalLimit = action((ctx, medical: number) => {\n const draft = ctx.get(policyDraftAtom);\n\n policyDraftAtom(ctx, produce(draft, (d) => {\n d.sections.coverage.limits.medical = medical;\n }));\n}, 'setMedicalLimit');\n```\n\nImmutability isn't a nice-to-have bolted onto the state graph. It's the thing that makes the graph work at all. The reactivity you get from Reatom is *paid for* by never mutating in place.\n\n## The Payoff\n\nEverything so far sounds like rules. Here's what the rules buy you, and it's the reason that small module survived becoming a large application.\n\n**Memoization actually works.** Because unchanged branches keep their references, `React.memo` and derived atoms skip work honestly. You don't fight spurious re-renders, because your data stops lying about what changed.\n\n**Undo becomes boring.** The [undo history from the Reatom article](/blog/react-playbook-reatom-state-management) only works because every previous draft is a distinct, frozen-in-time reference. If you mutated in place, \"history\" would just be a list of pointers to the same object, all mutating together. Immutability is what makes a snapshot an actual snapshot.\n\n```ts\nconst history: PolicyDraft[] = [];\n\n// every entry is a real, independent snapshot â because we never mutate\nhistory.push(currentDraft);\npolicyDraftAtom(ctx, nextDraft);\n```\n\n**Optimistic updates are safe.** You apply the optimistic value as a new reference, keep the previous one, and roll back to it if the request fails. No cloning gymnastics, no fear that the rollback value mutated underneath you.\n\n**Debugging tells the truth.** A logged state object stays what it was when you logged it. No more \"it was correct in the console but wrong in the UI\" â the classic symptom of an alias mutating after you inspected it.\n\nNone of these are exotic. They're the everyday properties that let a codebase absorb requirements it wasn't designed for. Which is exactly what that module had to do.\n\n## The Rule I Stopped Arguing About\n\nThe early pushback was really about ceremony. Immutability *looked* like ceremony â extra rules, extra keystrokes, extra discipline for a module nobody thought would grow.\n\n
1But the module grew, and the ceremony turned out to be the cheapest insurance we bought. Not because immutability is profound. Because it removed an entire category of bug â the silent, intermittent, \"works until it doesn't\" kind â right at the point where scale makes those bugs most expensive to find.\n\nI don't argue about immutability anymore. When someone asks whether it's worth the trouble on a small piece of code, I've stopped debating it. I just don't mutate. The habit costs almost nothing, and it holds up under exactly the pressure you can't predict when you're writing \"just one module.\"\n\nNext in the series, we move from data to what the user actually sees: [styling in React](/blog/react-playbook-css-styling), and why I keep coming back to Tailwind after trying most of the alternatives.\n\nIf you've got a state bug that only shows up sometimes â the kind that vanishes the moment you add a `console.log` â go looking for something mutating in place. And if you want to compare notes on where Immer genuinely earns its keep versus where it's overkill, reach out. I have opinions, and I'm happy to hear yours.\n"},{id:"react-playbook-reatom-state-management",title:"React State Management with Reatom: When useState Stops Being Enough",excerpt:"Most React state doesn't need a library. Then one workflow grows into drafts, derived values, undo, optimistic steps, and cross-component coordination. This is where I reach for Reatom instead of turning TanStack Query into a client-state store.",tags:["React","Reatom","State Management","Architecture","React Playbook"],date:"Mar 23, 2026",readTime:"13 min read",coverImage:"/blog/react-playbook-reatom-state-management/cover.png",content:"\nThe policy builder did not look dangerous at first.\n\nIt was \"just a form.\" A few sections, some conditional fields, a Save Draft button, a final Submit action. We already had TanStack Query for server state and TanStack Form for field state. It felt like the rest could live in component state.\n\nThen the requirements arrived one by one.\n\nIf the user changed coverage type, three sections had to recalculate. If they removed a beneficiary, dependent warnings had to disappear. If they switched tabs, the draft had to stay alive. If the API returned a validation warning, it had to attach to a section that might not be mounted. If they clicked Review, the app had to derive a summary from fields, rules, permissions, and unsaved changes.\n\nNothing was individually hard. Together, it became a graph.\n\nI've been in this room before. Different company, different domain, same shape. A workflow starts as local state. Then you add a context. Then a reducer. Then a few selectors. Then effects hidden in components. Eventually nobody can answer a simple question: *when this value changes, what else reacts?*\n\nThat is the point where I want a state management library.\n\nNot for every boolean. Not for every modal. For the moment client state becomes a reactive model instead of a component detail.\n\n> The question is not \"do we need global state?\" The question is \"does this workflow have a state graph?\"\n\nThis is article #7 in the **React Playbook** series. We already covered TanStack Query and TanStack Router. Now we need the missing piece: client state that does not belong to the server, but has outgrown `useState`.\n\n## Query Is Not Your Client Store\n\nTanStack Query owns server state.\n\nPolicies from the API. Customer records. Coverage types. Search results. Anything fetched, cached, invalidated, and synchronized with a backend.\n\nClient state is different:\n\n- which policy sections are expanded\n- current unsaved draft\n- selected rows in a table\n- multi-step wizard progress\n- temporary validation warnings\n- optimistic local operations before submit\n- undo history\n- derived review summary\n\nThe mistake is pushing these into Query because Query is already installed.\n\n```ts\nqueryClient.setQueryData(['policy-builder-draft'], draft);\n```\n\nThis works mechanically. It is also the wrong abstraction. Query cache is built around remote data identity, freshness, invalidation, refetching, and synchronization. A local draft is not stale. It should not refetch. It does not have server freshness semantics.\n\nUse Query for server state.\n\nUse a client-state tool for client state.\n\n## Why Not Just useState?\n\n`useState` is still the default.\n\nFor component-local state, it is perfect:\n\n```tsx\nconst [isOpen, setIsOpen] = useState(false);\nconst [selectedTab, setSelectedTab] = useState<'details' | 'activity'>('details');\n```\n\nDo not install a state library for that.\n\n`useReducer` is good when one component owns a local state machine:\n\n```tsx\nconst [state, dispatch] = useReducer(policyWizardReducer, initialWizardState);\n```\n\nStill fine.\n\nThe trouble starts when state has to be shared, derived, observed, and changed from multiple places.\n\nIn the policy builder:\n\n- The wizard footer needs to know whether any section is dirty.\n- The review page needs a derived summary.\n- Section components need local edits.\n- Autosave needs to react to draft changes.\n- Permission logic needs to disable actions.\n- Validation warnings need to attach to sections.\n\nYou can put all of this in context. But context broadcasts. Every consumer becomes part of the same render conversation. You can split contexts. Then you manage context architecture. You can write selectors. Then you are building a state library slowly, without the debugging tools or mental model.\n\nThat is where Reatom enters.\n\n## Why Reatom\n\nI like Reatom for workflows where state is a graph.\n\nSmall atoms. Derived atoms. Actions. Effects. Explicit dependencies. React components subscribe to the pieces they read, not to a giant provider value.\n\nThe important thing is not the library name. The important thing is the model:\n\n> Break client state into small atoms, derive what can be derived, and make actions the place where transitions happen.\n\nThat model fits complex frontend workflows better than a single reducer object or a large Zustand store.\n\nZustand is excellent for simple global stores. I use it happily for small apps: auth-ish UI state, sidebar preferences, feature flags, lightweight cross-component state.\n\nBut for CRM-scale workflows with derived state, undo, chained reactions, and local process logic, I prefer a graph. Reatom makes the graph visible.\n\n## A Small Policy Builder Model\n\nImagine the policy builder feature:\n\n```\nsrc/\n features/\n build-policy/\n model/\n policy-builder.atoms.ts\n policy-builder.actions.ts\n policy-builder.selectors.ts\n ui/\n PolicyBuilderPage.tsx\n CoverageSection.tsx\n BeneficiariesSection.tsx\n ReviewPanel.tsx\n index.ts\n```\n\nThe model belongs to the feature because the state exists for this workflow. It is not an entity. It is not shared. It is not a generic app store.\n\nStart with atoms:\n\n```ts\n// src/features/build-policy/model/policy-builder.atoms.ts\nimport { atom } from '@reatom/core';
1\n\nexport interface PolicyDraft {\n holderName: string;\n coverageType: 'standard' | 'premium';\n beneficiaries: BeneficiaryDraft[];\n}\n\nexport const policyDraftAtom = atom<PolicyDraft>(\n {\n holderName: '',\n coverageType: 'standard',\n beneficiaries: [],\n },\n 'policyDraftAtom',\n);\n\nexport const activeSectionAtom = atom<PolicySection>('coverage', 'activeSectionAtom');\n\nexport const dirtySectionsAtom = atom<Set<PolicySection>>(\n new Set(),\n 'dirtySectionsAtom',\n);\n```\n\nSmall atoms. Named atoms. No giant `policyBuilderStore` object with everything inside.\n\nWhy?\n\nBecause different components care about different parts. The coverage section should not rerender because the beneficiaries list changed. The review panel might care about the full draft, but the active tab indicator only cares about `activeSectionAtom`.\n\n## Derived State Should Be Derived\n\nDirty state often gets stored badly.\n\n```ts\nconst [canSubmit, setCanSubmit] = useState(false);\n```\n\nThen you remember to update it after every draft change, every validation change, every permission change. Or you forget, and the button lies.\n\nDerived state should be computed from source state:\n\n```ts\n// src/features/build-policy/model/policy-builder.selectors.ts\nimport { atom } from '@reatom/core';\nimport { dirtySectionsAtom, policyDraftAtom } from './policy-builder.atoms';\n\nexport const hasUnsavedChangesAtom = atom((ctx) => {\n return ctx.spy(dirtySectionsAtom).size > 0;\n}, 'hasUnsavedChangesAtom');\n\nexport const reviewSummaryAtom = atom((ctx) => {\n const draft = ctx.spy(policyDraftAtom);\n\n return {\n holder: draft.holderName || 'Missing holder name',\n coverage: draft.coverageType,\n beneficiaryCount: draft.beneficiaries.length,\n };\n}, 'reviewSummaryAtom');\n```\n\nThe exact Reatom API can vary by package setup, but the principle is stable: derived atoms observe source atoms and recompute when dependencies change.\n\nNo manual synchronization.\n\nNo effect that says \"when A changes, update B.\"\n\nB is a function of A.\n\n## Actions Are Transitions\n\nI do not want random components mutating atoms however they like.\n\nFor meaningful workflow changes, define actions:\n\n```ts\n// src/features/build-policy/model/policy-builder.actions.ts\nimport { action } from '@reatom/core';\nimport { dirtySectionsAtom, policyDraftAtom } from './policy-builder.atoms';\n\nexport const updateCoverageType = action(\n (ctx, coverageType: PolicyDraft['coverageType']) => {\n const draft = ctx.get(policyDraftAtom);\n\n policyDraftAtom(ctx, {\n ...draft,\n coverageType,\n });\n\n const dirtySections = new Set(ctx.get(dirtySectionsAtom));\n dirtySections.add('coverage');\n dirtySectionsAtom(ctx, dirtySections);\n },\n 'updateCoverageType',\n);\n```\n\nThe coverage type change is not just a setter. It changes the draft and marks a section dirty. Later it might clear dependent fields. Later it might add a warning. The action is where that transition belongs.\n\nThis is the main reason I like action-based state models. They create names for product events:\n\n- `updateCoverageType`\n- `removeBeneficiary`\n- `restoreDraft`\n- `markSectionReviewed`\n- `resetBuilder`\n\nThose names are easier to reason about than a trail of `setState` calls.\n\n## React Components Stay Small\n\nThe component should read state and dispatch intent.\n\n```tsx\n// src/features/build-policy/ui/CoverageSection.tsx\nimport { reatomComponent } from '@reatom/npm-react';\nimport { policyDraftAtom } from '../model/policy-builder.atoms';\nimport { updateCoverageType } from '../model/policy-builder.actions';\n\nexport const CoverageSection = reatomComponent(({ ctx }) => {\n const draft = ctx.spy(policyDraftAtom);\n\n return (\n <section>\n <h2>Coverage</h2>\n <CoverageTypeSelect\n value={draft.coverageType}\n onChange={(coverageType) => updateCoverageType(ctx, coverageType)}\n />\n </section>\n );\n}, 'CoverageSection');\n```\n\nThe component does not know how dirty state works. It does not know which depen
1dent values reset. It says: the user changed coverage type.\n\nThe model handles the transition.\n\nThat separation is the payoff.\n\n## Where Reatom Meets TanStack Query\n\nQuery loads the server state. Reatom owns the local workflow.\n\nWhen opening an existing policy builder, seed the draft from Query data:\n\n```tsx\nexport function PolicyBuilderRoute({ policyId }: { policyId: string }) {\n const policyQuery = useQuery(policyDetailQueryOptions(policyId));\n\n if (policyQuery.isPending) return <PolicyBuilderSkeleton />;\n if (policyQuery.isError) return <PolicyBuilderError onRetry={() => policyQuery.refetch()} />;\n\n return <PolicyBuilderPage initialPolicy={policyQuery.data} />;\n}\n```\n\nThen initialize the feature model at the boundary:\n\n```tsx\nexport function PolicyBuilderPage({ initialPolicy }: { initialPolicy: PolicyDetail }) {\n const draft = mapPolicyToDraft(initialPolicy);\n\n return (\n <PolicyBuilderScope initialDraft={draft}>\n <CoverageSection />\n <BeneficiariesSection />\n <ReviewPanel />\n </PolicyBuilderScope>\n );\n}\n```\n\nThe important boundary: server data becomes an initial draft. After that, the draft is client state.\n\nWhen the user saves:\n\n```ts\nexport const savePolicyDraft = action(async (ctx) => {\n const draft = ctx.get(policyDraftAtom);\n\n await updatePolicyDraft(draft);\n\n dirtySectionsAtom(ctx, new Set());\n}, 'savePolicyDraft');\n```\n\nThen invalidate Query data if needed:\n\n```ts\nqueryClient.invalidateQueries({ queryKey: policyKeys.detail(policyId) });\n```\n\nDo not keep the Query cache and draft atom synchronized on every keystroke. That turns two clear models into one blurry model.\n\n## Effects Need Discipline\n\nAutosave is a good example.\n\nIt is tempting to put autosave in a component:\n\n```tsx\nuseEffect(() => {\n const id = setTimeout(() => saveDraft(draft), 1000);\n return () => clearTimeout(id);\n}, [draft]);\n```\n\nThat works until the component unmounts, remounts, gets split, or the draft changes from somewhere else.\n\nFor serious workflows, autosave should live near the state model. It reacts to the model, not to a particular component being mounted.\n\nThe exact Reatom effect API depends on setup, but the shape is what matters:\n\n```ts\nexport const autosaveDraft = action(async (ctx) => {\n const draft = ctx.get(policyDraftAtom);\n const hasChanges = ctx.get(hasUnsavedChangesAtom);\n\n if (!hasChanges) return;\n\n await savePolicyDraftToServer(draft);\n dirtySectionsAtom(ctx, new Set());\n}, 'autosaveDraft');\n```\n\nThen schedule or trigger it from the feature boundary, not from every field.\n\nEffects should be named. Effects should be cancellable when needed. Effects should not hide in five components.\n\n## Undo and History\n\nThis is where graph-based state starts to feel worth it.\n\nUndo is awkward when state is scattered across components. It is manageable when workflow state has a model.\n\n```ts\nexport const draftHistoryAtom = atom<PolicyDraft[]>([], 'draftHistoryAtom');\n\nexport const commitDraftChange = action((ctx, nextDraft: PolicyDraft) => {\n const currentDraft = ctx.get(policyDraftAtom);\n const history = ctx.get(draftHistoryAtom);\n\n draftHistoryAtom(ctx, [...history, currentDraft]);\n policyDraftAtom(ctx, nextDraft);\n}, 'commitDraftChange');\n\nexport const undoDraftChange = action((ctx) => {\n const history = ctx.get(draftHistoryAtom);\n const previousDraft = history.at(-1);\n\n if (!previousDraft) return;\n\n draftHistoryAtom(ctx, history.slice(0, -1));\n policyDraftAtom(ctx, previousDraft);\n}, 'undoDraftChange');\n```\n\nWould I add undo to every form? No.\n\nBut when the product needs it, I want the state model to make it boring.\n\n## What Not to Put in Reatom\n\nReatom is not a dumping ground.\n\nDo not put this in Reatom by default:\n\n- fetched server data that belongs in TanStack Query\n- simple component booleans\n- URL state that belongs in TanStack Router search params\n- form field state already owned cleanly by TanStack Form\n- one-off UI state that never leaves a component\n\nThe fact that a library can hold state does not mean it should hold all state.\n\nMy default split:\n\n- **TanStack Query:** server state\n- **TanStack Router:** URL/navigation state\n- **TanStack Form:** form field state and validation flow\n- **Reatom:** complex client workflows and reactive graphs\n- **useState:** local component details\n\nThis split keeps each tool honest.\n\n## The Review Checklist\n\nWhen I review client state, I ask:\n\n- Is this state server-owned, URL-owned, form-owned, or client-owned?\n- Can it stay local with `useState`?\n- Is this derived value being stored manually?\n- Are c
1omponents changing atoms directly, or dispatching named actions?\n- Does the feature model live near the feature?\n- Are effects named and centralized, or hidden in component trees?\n- Is Query cache being abused as a local store?\n- Can a new developer see the state graph without opening ten components?\n\nThe last question matters most. State management is not about reducing lines. It is about making change propagation understandable.\n\n## What Comes Next\n\nReatom gives us a way to model complex client workflows. The next article goes one layer deeper: [immutable data structures in React](/blog/react-playbook-immutable-data-structures). Not as a purity lecture, but as the practical reason state graphs, memoization, optimistic updates, and undo histories stay predictable.\n\n---\n\nThe policy builder eventually stopped feeling like a form and started feeling like a small application. That was the important realization. Treating it as \"just component state\" made every requirement feel like an exception. Treating it as a workflow model made the requirements fit somewhere.\n\nNot because Reatom was magic.\n\nBecause the state graph finally had a shape.\n\nIf you're working on a React feature where every small change triggers three unrelated effects, reach out. The problem may not be your components. It may be that the workflow wants a model of its own.\n"},{id:"react-playbook-tanstack-router",title:"Routing with TanStack Router: Treat the URL Like Application State",excerpt:"Routing is not just rendering a page for a path. In serious React apps, the URL carries product state: filters, tabs, pagination, selected records, redirects, and preload decisions. TanStack Router makes that state typed instead of hopeful.",tags:["React","TanStack Router","Routing","TypeScript","React Playbook"],date:"Mar 19, 2026",readTime:"13 min read",coverImage:"/blog/react-playbook-tanstack-router/cover.png",content:"\nThe policy list bug started as a support ticket.\n\nAn agent filtered policies by status, page, and renewal date. She copied the URL and sent it to another agent. The second agent opened it and saw a different list.\n\nSame page. Same product. Different state.\n\nThe filters were not in the URL. The page number lived in local component state. The selected tab lived in a store. The search box was debounced into a fetch call, but never synchronized back to navigation. The route looked shareable. It wasn't.\n\nHonestly, that bug changed how I think about routing.\n\nRouting is not just \"which component renders for this path.\" That is the small version. In product software, routing is about preserving intent. What screen is the user on? Which record? Which filters? Which tab? Which page? Can they reload and get the same view? Can they send the link to someone else and trust it?\n\nI've seen teams treat the URL as decoration. Then they spend months rebuilding browser behavior manually.\n\nTanStack Router gives React apps a better option: typed routes, typed params, typed search, loaders, redirects, preloading, and integration with TanStack Query. The point is not fancy routing. The point is making navigation state explicit.\n\n> A URL is not a string you push into history. It is part of your application's state model.\n\nThis is article #6 in the **React Playbook** series. We already covered the stack, TypeScript patterns, code structure, error handling, and TanStack Query. Now we connect those pieces through routing.\n\n## Why TanStack Router\n\nReact Router is familiar. It works. I used it for years.\n\nThe reason I reach for TanStack Router in new TypeScript-heavy apps is not that React Router is bad. It is that TanStack Router makes more route mistakes impossible.\n\nWith TanStack Router, routes are registered into a typed route tree. That means:\n\n- `<Link to=\"/policies/$policyId\">` knows which params are required.\n- `useParams({ from: '/policies/$policyId' })` returns the right param type.\n- `useSearch({ from: '/policies' })` returns validated search state.\n- `navigate({ to, params, search })` is checked against the actual route.\n- Loaders can use route context and params safely.\n\nThe difference is subtle until a route changes.\n\nRename `$policyId` to `$id`, and TypeScript tells you everywhere the old param is still used. Remove a search param, and navigation calls fail to compile. That is the kind of friction I want.\n\nNot because I enjoy types for their own sake.\n\nBecause broken links and stale URL assumptions are production bugs.\n\n## File-Based or Code-Based Routes?\n\nTanStack Router supports both file-based and code-based routing.\n\nFor large teams, I like file-based routing. It creates a predictable structure, works well with route code splitting, and keeps route files close to pages.\n\nFor teaching and small early projects, code-based routing is easier to see in one place.\n\nThis article uses a file-based shape because it is what I would use once the app grows:\n\n```\nsrc/\n routes/\n __root.tsx\n index.tsx\n policies/\n index.tsx\n $policyId.tsx\n $policyId.edit.tsx\n routeTree.gen.ts\n```\n\nIn an FSD-style project, I still keep page UI in `pages/` and let route files wire it:\n\n```\nsrc/\n routes/\n policies/\n index.tsx\n $policyId.tsx\n pages/\n policy-list/\n PolicyListPage.tsx\n policy-detail/\n PolicyDetailPage.tsx\n```\n\nRoute files are integration points. They connect URL shape, loader behavior, search validation, and page components. They should not become business logic containers.\n\n## The Root Route\n\nThe root route is where app-level layout and route context begin.\n\n```tsx\n// src/routes/__root.tsx\nimport { Outlet, createRootRouteWithContext } from '@tanstack/react-router';\nimport type { QueryClient } from '@tanstack/react-query';\nimport { RootLayout } from '@/app/RootLayout';\n\ninterface RouterContext {\n queryClient: QueryClient;\n auth: AuthSession | null;\n}\n\nexport const Route = createRootRouteWithContext<RouterContext>()({\n component: RootRoute,\n});\n\nfunction RootRoute() {\n return (\n <RootLayout>\n <Outlet />\n </RootLayout>\n );\n}\n```\n\nThat context matters. Later, route loaders can access `queryClient` without importing a singleton. Auth checks can use the current session. Tests can provide a different c
1ontext.\n\nThen create the router:\n\n```ts\n// src/app/router.ts\nimport { createRouter } from '@tanstack/react-router';\nimport { routeTree } from '@/routeTree.gen';\nimport { queryClient } from './query-client';\nimport { authSession } from './auth-session';\n\nexport const router = createRouter({\n routeTree,\n context: {\n queryClient,\n auth: authSession,\n },\n});\n\ndeclare module '@tanstack/react-router' {\n interface Register {\n router: typeof router;\n }\n}\n```\n\nThe `Register` declaration is the bridge that gives the rest of the app type-safe navigation. Skip it and you lose much of the point.\n\n## Route Params Should Be Boring\n\nA policy detail route:\n\n```tsx\n// src/routes/policies/$policyId.tsx\nimport { createFileRoute } from '@tanstack/react-router';\nimport { PolicyDetailPage } from '@/pages/policy-detail';\n\nexport const Route = createFileRoute('/policies/$policyId')({\n component: PolicyDetailRoute,\n});\n\nfunction PolicyDetailRoute() {\n const { policyId } = Route.useParams();\n\n return <PolicyDetailPage policyId={policyId} />;\n}\n```\n\n`policyId` is typed from the route path. No manual generic. No `as string`. No \"I hope this param exists.\"\n\nNavigation is typed too:\n\n```tsx\nimport { Link } from '@tanstack/react-router';
1\n\nexport function PolicyRow({ policy }: { policy: Policy }) {\n return (\n <Link to=\"/policies/$policyId\" params={{ policyId: policy.id }}>\n {policy.policyNumber}\n </Link>\n );\n}\n```\n\nIf you forget `policyId), TypeScript complains. If you pass `id` instead of `policyId`, TypeScript complains. That is boring in the best possible way.\n\n## Search Params Are Product State\n\nThe policy list route is where TanStack Router starts to feel different.\n\nFilters belong in the URL:\n\n```\n/policies?status=active&page=2&query=miller\n```\n\nNot because URLs are pretty. Because users reload pages, bookmark views, share links, and go back in browser history.\n\nDefine the search shape:\n\n```tsx\n// src/routes/policies/index.tsx\nimport { createFileRoute } from '@tanstack/react-router';\nimport { z } from 'zod';\nimport { PolicyListPage } from '@/pages/policy-list';\n\nconst policyListSearchSchema = z.object({\n status: z.enum(['active', 'expired', 'pending']).optional(),\n page: z.number().int().positive().catch(1),\n query: z.string().catch(''),\n});\n\nexport const Route = createFileRoute('/policies/')({\n validateSearch: policyListSearchSchema,\n component: PolicyListRoute,\n});\n\nfunction PolicyListRoute() {\n const search = Route.useSearch();\n\n return <PolicyListPage filters={search} />;\n}\n```\n\nNow `search.page` is a number. Not a string you parsed manually. Not `string | undefined`. A number.\n\nThis matters because search params are the easiest place for type lies to enter an app. The browser gives you strings. Your product wants domain state. TanStack Router gives you a validation boundary.\n\n## Updating Search Without Losing State\n\nWhen the user changes a filter:\n\n```tsx\nimport { useNavigate } from '@tanstack/react-router';
1\n\nexport function PolicyFilters({ filters }: { filters: PolicyListFilters }) {\n const navigate = useNavigate({ from: '/policies/' });\n\n return (\n <StatusSelect\n value={filters.status}\n onChange={(status) => {\n navigate({\n search: (prev) => ({\n ...prev,\n status,\n page: 1,\n }),\n });\n }}\n />\n );\n}\n```\n\nNotice the `page: 1`.\n\nChanging status should reset pagination. This is a product rule. Put it where navigation state changes. Otherwise you end up on page 7 of a filter that only has one page and someone files a \"missing policies\" bug.\n\nFor pagination:\n\n```tsx\nexport function PolicyPagination({ page }: { page: number }) {\n const navigate = useNavigate({ from: '/policies/' });\n\n return (\n <Pagination\n page={page}\n onPageChange={(nextPage) => {\n navigate({\n search: (prev) => ({\n ...prev,\n page: nextPage,\n }),\n });\n }}\n />\n );\n}\n```\n\nNo local `useState`. The URL is the state.\n\n## Connecting Router to Query\n\nArticle #5 covered query options. Routing is where they pay off.\n\n```tsx\n// src/routes/policies/index.tsx\nexport const Route = createFileRoute('/policies/')({\n validateSearch: policyListSearchSchema,\n loaderDeps: ({ search }) => ({ search }),\n loader: ({ context, deps }) =>\n context.queryClient.ensureQueryData(policiesQueryOptions(deps.search)),\n component: PolicyListRoute,\n});\n```\n\nThe route validates search. The loader uses validated search to warm the query cache. The page subscribes to the same query:\n\n```tsx\nfunction PolicyListRoute() {\n const search = Route.useSearch();\n const policiesQuery = useQuery(policiesQueryOptions(search));\n\n return <PolicyListPage filters={search} policiesQuery={policiesQuery} />;\n}\n```\n\nOne data definition. Two usages.\n\nThat is the shape I want: route loaders handle navigation-level data readiness; components still use Query for cache subscription, background refetching, and UI state.\n\nDon't fetch in the loader and pass raw data into the component while also using Query elsewhere. That creates two data paths. Use the loader to fill the cache.\n\n## Detail Routes and Not Found\n\nFor detail pages:\n\n```tsx\n// src/routes/policies/$policyId.tsx\nimport { createFileRoute, notFound } from '@tanstack/react-router';\nimport { policyDetailQueryOptions } from '@/entities/policy';\nimport { isNotFoundError } from '@/shared/api';\nimport { PolicyDetailPage } from '@/pages/policy-detail';\n\nexport const Route = createFileRoute('/policies/$policyId')({\n loader: async ({ context, params }) => {\n try {\n return await context.queryClient.ensureQueryData(\n policyDetailQueryOptions(params.policyId),\n );\n } catch (error) {\n if (isNotFoundError(error)) throw notFound();\n throw error;\n }\n },\n component: PolicyDetailRoute,\n notFoundComponent: PolicyNotFound,\n errorComponent: PolicyRouteError,\n});\n\nfunction PolicyDetailRoute() {\n const { policyId } = Route.useParams();\n const policyQuery = useQuery(policyDetailQueryOptions(policyId));\n\n return <PolicyDetailPage policy={policyQuery.data} />;\n}\n```\n\nThe route decides whether the page can exist. A missing policy is not the same as a temporary server failure. This lines up with the error-handling model from article #4.\n\n## Route Guards with beforeLoad\n\nSome routes require authentication or permissions.\n\nUse `beforeLoad` for route-level guards:\n\n```tsx\n// src/routes/admin.tsx\nimport { createFileRoute, redirect } from '@tanstack/react-router';\nimport { AdminPage } from '@/pages/admin';\n\nexport const Route = createFileRoute('/admin')({\n beforeLoad: ({ context, location }) => {\n if (!context.auth) {\n throw redirect({\n to: '/login',\n search: {\n redirectTo: location.href,\n },\n });\n }\n\n if (context.auth.role !== 'admin') {\n throw redirect({ to: '/policies' });\n }\n },\n component: AdminPage,\n});\n```\n\nThis is not a replacement for backend authorization. Never trust the frontend for security.\n\nBut it is the right place to keep users out of UI they cannot use. It also preserves intent: after login, send them back to where they tried to go.\n\n## Preloading on Intent\n\nRouting should feel instant when intent is clear.\n\nTanStack Router can preload routes through links. TanStack Query can prefetch query data. Use both deliberately.\n\n```tsx\nexport function PolicyRow({ policy }: { policy: Policy }) {\n return (\n <Link\n to=\"/policies/$policyId\"\n params={{ policyId: policy.id }}
1\n preload=\"intent\"\n >\n {policy.policyNumber}\n </Link>\n );\n}\n```\n\nWhen the user hovers or focuses the link, the router can prepare the destination. If your route loader calls `ensureQueryData`, the query cache warms too.\n\nThat is the clean integration: link intent -> route preload -> loader -> query cache.\n\nThe user clicks, and the page is already halfway there.\n\n## Use Route APIs Inside Route Components\n\nTanStack Router gives each route a `Route` object. Use it.\n\n```tsx\nfunction PolicyListRoute() {\n const search = Route.useSearch();\n const navigate = Route.useNavigate();\n\n // ...\n}\n```\n\nThis is more precise than global hooks when you know which route you are in. The types are anchored to that route. Refactors stay local.\n\nIn leaf page components, I still prefer passing explicit props:\n\n```tsx\nfunction PolicyListRoute() {\n const search = Route.useSearch();\n const policiesQuery = useQuery(policiesQueryOptions(search));\n\n return <PolicyListPage filters={search} policiesQuery={policiesQuery} />;\n}\n```\n\nWhy not let `PolicyListPage` call `Route.useSearch()` directly?\n\nSometimes that is fine. But I like keeping page components easier to test and less coupled to router internals. The route knows about routing. The page knows about rendering the product view.\n\n## Don't Hide Navigation in Random Helpers\n\nOne pattern I avoid:\n\n```ts\nexport function openPolicy(policyId: string) {\n router.navigate({ to: '/policies/$policyId', params: { policyId } });\n}\n```\n\nIt looks convenient. It spreads navigation decisions into random helpers. Over time, you get hidden redirects, surprising history behavior, and navigation that bypasses route-local search state.\n\nPrefer explicit links and route-local navigation. If a navigation pattern repeats, create a component:\n\n```tsx\nexport function PolicyLink({\n policyId,\n children,\n}: {\n policyId: string;\n children: React.ReactNode;\n}) {\n return (\n <Link to=\"/policies/$policyId\" params={{ policyId }} preload=\"intent\">\n {children}\n </Link>\n );\n}\n```\n\nNow navigation stays visible in the UI tree.\n\n## The Review Checklist\n\nWhen I review routing code, I ask:\n\n- Is shareable product state in the URL?\n- Are search params validated at the route boundary?\n- Do search changes preserve or reset related state intentionally?\n- Do route loaders reuse TanStack Query options instead of inventing separate fetch paths?\n- Are detail routes handling not-found separately from server errors?\n- Are protected routes guarded in `beforeLoad`?\n- Are links using typed `to`, `params`, and `search` instead of string concatenation?\n- Is preloading used where user intent is clear?\n- Are page components receiving explicit props when that keeps them easier to test?\n\nMost routing bugs are not about paths. They are about state that should have been in the URL but wasn't.\n\n## What Comes Next\n\nWith Query and Router in place, the next question is client state: the data that does not live on the server but still becomes too complex for `useState`.\n\n**Article #7:** [state management with Reatom](/blog/react-playbook-reatom-state-management) â why I use it in CRM-scale React apps, where it fits next to TanStack Query, and why I don't reach for Zustand by default in this particular stack.\n\n---\n\nAfter the policy-list support ticket, we moved filters, page, and search into route search params. The bug disappeared. More importantly, the product started behaving like a web app again. Refresh worked. Back worked. Shared links worked. QA could send exact states to developers instead of screenshots and vague reproduction steps.\n\nThat is what routing should do.\n\nNot just render components.\n\nPreserve intent.\n\nIf your React app has pages that look shareable but aren't, start with the URL. The fix is often not another store. It is admitting that the route already owns more state than your code says it does.\n"},{id:"react-playbook-data-fetching",title:"React Data Fetching with TanStack Query: The Patterns That Keep Apps Calm",excerpt:"TanStack Query is not just a better useEffect. It is the server-state layer of your React app. The hard part is not fetching data â it is designing keys, freshness, invalidation, pagination, and prefetching so the UI stays predictable.",tags:["React","TanStack Query","Data Fetching","TypeScript","React Playbook"],date:"Mar 17, 2026",readTime:"13 min read",coverImage:"/blog/react-playbook-data-fetching/cover.png",content:"\nThree months into the insurance CRM, we had a page that felt haunted.\n\nThe policy list would load. Then it would blink. Then it would refetch after switching tabs. Then a status change would update one row but leave another count stale. Sometimes the detail page showed fresh data. Sometimes it showed yesterday's version until someone manually refreshed the browser.\n\nNobody had written obviously bad code.\n\nThere was a `useEffect` here, a loading boolean there, a bit of local state for filters, a manual refetch after save, and a few defensive checks around undefined data. Each decision made sense in isolation. Together, they formed the kind of frontend state machine nobody wants to debug.\n\nI've seen this pattern more times than I can count. Teams think their data-fetching problem is \"how do we call the API?\" That is the easy part. The real problem is ownership: who decides whether data is fresh, where it is cached, what invalidates it, what happens during pagination, and what the user sees while the network is moving.\n\nThat is why TanStack Query matters.\n\nNot because it fetches.\n\nBecause it gives server state a home.\n\n> The hardest part of data fetching is not the request. It is the lifecycle around the request.\n\nThis is article #5 in the **React Playbook** series. We already covered the starting stack, TypeScript patterns, code structure, and error handling. Now we get to the layer that makes most React apps feel either calm or twitchy: TanStack Query.\n\n## Server State Is Not Client State\n\nThe first mental shift is simple but important.\n\nServer state is not yours.\n\nIt lives somewhere else. It can change without your app knowing. It can be stale. It can fail. It can be shared by multiple users. It has a network boundary, a cache lifetime, and a synchronization problem.\n\nClient state is different. A dialog is open or closed. A sidebar is collapsed. A draft field has local input. That state belongs to the browser session.\n\nThe mistake is mixing the two.\n\n```tsx\nconst [policies, setPolicies] = useState<Policy[]>([]);\nconst [isLoading, setIsLoading] = useState(false);\nconst [error, setError] = useState<Error | null>(null);\n\nuseEffect(() =>
1 {\n setIsLoading(true);\n\n fetchPolicies()\n .then(setPolicies)\n .catch(setError)\n .finally(() => setIsLoading(false));\n}, []);\n```\n\nThis looks harmless. It is fine for a demo. In production, it immediately raises questions:\n\n- What happens when another component needs the same policies?\n- How long is this data fresh?\n- Who retries?\n- Who cancels the request if the component unmounts?\n- What invalidates this list after a mutation?\n- What happens when filters change?\n- What happens when the user leaves and comes back?\n\nYou can answer all of those manually. You can also spend the next year rediscovering why caching is hard.\n\nTanStack Query is the place where those answers live.\n\n## Start with Query Options, Not Custom Hooks Everywhere\n\nMy default pattern in larger apps is to define query options at the entity boundary.\n\n```ts\n// src/entities/policy/api/policy.queries.ts\nimport { queryOptions } from '@tanstack/react-query';\nimport { fetchPolicies, fetchPolicyById } from './policy.api';\nimport type { PolicyListFilters } from '../model/policy.types';\n\nexport const policyKeys = {\n all: ['policies'] as const,\n lists: () => [...policyKeys.all, 'list'] as const,\n list: (filters: PolicyListFilters) => [...policyKeys.lists(), filters] as const,\n details: () => [...policyKeys.all, 'detail'] as const,\n detail: (policyId: string) => [...policyKeys.details(), policyId] as const,\n};\n\nexport function policiesQueryOptions(filters: PolicyListFilters) {\n return queryOptions({\n queryKey: policyKeys.list(filters),\n queryFn: () => fetchPolicies(filters),\n staleTime: 1000 * 60 * 2,\n });\n}\n\nexport function policyDetailQueryOptions(policyId: string) {\n return queryOptions({\n queryKey: policyKeys.detail(policyId),\n queryFn: () => fetchPolicyById(policyId),\n staleTime: 1000 * 60 * 5,\n });\n}\n```\n\nThen components use the options:\n\n```tsx\n// src/pages/policy-list/PolicyListPage.tsx\nimport { useQuery } from '@tanstack/react-query';\nimport { policiesQueryOptions } from '@/entities/policy';\n\nexport function PolicyListPage() {\n const filters = usePolicyListFilters();\n const policiesQuery = useQuery(policiesQueryOptions(filters));\n\n if (policiesQuery.isPending) return <PolicyListSkeleton />;\n if (policiesQuery.isError) return <PolicyListError onRetry={() => policiesQuery.refetch()} />;\n\n return <PolicyTable policies={policiesQuery.data.items} />;\n}\n```\n\nWhy not just create `usePoliciesQuery(filters)`?\n\nSometimes I do. But query options compose better. The same object can be used by `useQuery`, route loaders, prefetching, tests, and invalidation helpers. It keeps the query definition in one place without forcing every usage through a hook.\n\nThe hook becomes optional sugar, not the source of truth.\n\n## Query Keys Are Architecture\n\nQuery keys look like small arrays. They are not small.\n\nThey are the identity of your server state.\n\nIf your keys are inconsistent, invalidation becomes guesswork:\n\n```ts\nuseQuery({ queryKey: ['policy', policyId], queryFn: ... });\nuseQuery({ queryKey: ['policies', 'detail', id], queryFn: ... });\nqueryClient.invalidateQueries({ queryKey: ['policy'] });\n```\n\nThis is how stale UI survives after a successful mutation.\n\nUse factories:\n\n```ts\nexport const policyKeys = {\n all: ['policies'] as const,\n lists: () => [...policyKeys.all, 'list'] as const,\n list: (filters: PolicyListFilters) => [...policyKeys.lists(), filters] as const,\n details: () => [...policyKeys.all, 'detail'] as const,\n detail: (policyId: string) => [...policyKeys.details(), policyId] as const,\n};\n```\n\nThis gives you hierarchy:\n\n```ts\nqueryClient.invalidateQueries({ queryKey: policyKeys.lists() });\nqueryClient.invalidateQueries({ queryKey: policyKeys.detail(policyId) });\nqueryClient.invalidateQueries({ queryKey: policyKeys.all });\n```\n\nInvalidate all policies. Or only lists. Or one detail. The key shape makes the choice explicit.\n\nThe rule is straightforward:\n\n> If a variable changes what the query fetches, that variable belongs in the query key.\n\nFilters, page number,
1cursor, search term, sort order, preview mode, tenant id. Put them in the key. TanStack Query cannot cache what you do not identify.\n\n## Freshness Is a Product Decision\n\nTanStack Query's defaults are intentionally active: cached data is stale by default, stale queries refetch on mount, window focus, and reconnect, and inactive queries are garbage-collected after a default cache window.\n\nThose defaults are good for learning and safe for many apps. They are not a substitute for product thinking.\n\nIn a CRM, different data has different freshness needs.\n\n```ts\nexport function policyDetailQueryOptions(policyId: string) {\n return queryOptions({\n queryKey: policyKeys.detail(policyId),\n queryFn: () => fetchPolicyById(policyId),\n staleTime: 1000 * 60 * 5,\n });\n}\n\nexport function policyActivityQueryOptions(policyId: string) {\n return queryOptions({\n queryKey: [...policyKeys.detail(policyId), 'activity'],\n queryFn: () => fetchPolicyActivity(policyId),\n staleTime: 1000 * 30,\n });\n}\n\nexport function coverageTypesQueryOptions() {\n return queryOptions({\n queryKey: ['coverage-types'],\n queryFn: fetchCoverageTypes,\n staleTime: 1000 * 60 * 60,\n gcTime: 1000 * 60 * 60,\n });\n}\n```\n\nPolicy detail can be fresh for five minutes. Activity should refresh more aggressively. Coverage types barely change during a session.\n\nThis is the kind of decision I want visible in code. If every query inherits one global `staleTime`, you are pretending all data has the same volatility.\n\nIt doesn't.\n\n## Background Fetching Without UI Jank\n\nOne of TanStack Query's best features is that it separates initial loading from background fetching.\n\n```tsx\nexport function PolicyListPage() {\n const filters = usePolicyListFilters();\n const policiesQuery = useQuery(policiesQueryOptions(filters));\n\n if (policiesQuery.isPending) return <PolicyListSkeleton />;\n if (policiesQuery.isError) return <PolicyListError onRetry={() => policiesQuery.refetch()} />;\n\n return (\n <>\n {policiesQuery.isFetching && <RefreshingIndicator />}\n <PolicyTable policies={policiesQuery.data.items} />\n </>\n );\n}\n```\n\n`isPending` means there is no data yet.\n\n`isFetching` means a request is currently happening.\n\nThose are not the same UX. If you show a full-page skeleton every time `isFetching` is true, your app will blink constantly. Keep the old data visible. Show a small refreshing indicator if the user needs to know.\n\nThis is one of the places where React apps start feeling calm.\n\n## Pagination Without Flicker\n\nPagination is where naive data fetching becomes obvious.\n\n```tsx\nconst policiesQuery = useQuery({\n queryKey: ['policies', page],\n queryFn: () => fetchPolicies({ page }),\n});\n```\n\nTechnically correct. Bad experience.\n\nEvery page is a different query key, so moving from page 1 to page 2 can bounce the UI back into a pending state. For tables, that feels like the screen is being rebuilt under the user's hands.\n\nUse `placeholderData: keepPreviousData`:\n\n```tsx\nimport { keepPreviousData, useQuery } from '@tanstack/react-query';\n\nexport function PolicyListPage() {\n const [page, setPage] = useState(1);\n const filters = usePolicyListFilters();\n\n const policiesQuery = useQuery({\n ...policiesQueryOptions({ ...filters, page }),\n placeholderData: keepPreviousData,\n });\n\n if (policiesQuery.isPending) return <PolicyListSkeleton />;\n if (policiesQuery.isError) return <PolicyListError onRetry={() => policiesQuery.refetch()} />;\n\n return (\n <>\n <PolicyTable\n policies={policiesQuery.data.items}\n faded={policiesQuery.isPlaceholderData}\n />\n <Pagination\n page={page}\n hasNextPage={policiesQuery.data.hasNextPage}\n disabled={policiesQuery.isPlaceholderData}\n onPageChange={setPage}\n />\n </>\n );\n}\n```\n\nNow the previous page stays visible while the next page loads. When the new data arrives, it swaps in. The UI doesn't fall through loading states just because the query key changed.\n\nSmall detail. Huge difference.\n\n## Dependent Queries\n\nSome data needs another piece of data first.\n\nThe policy detail page loads the policy. The
1n it may load documents for the policy's customer account. You can't fetch those documents until you know the account id.\n\n```tsx\nexport function PolicyDetailPage() {\n const { policyId } = Route.useParams();\n\n const policyQuery = useQuery(policyDetailQueryOptions(policyId));\n\n const documentsQuery = useQuery({\n ...customerDocumentsQueryOptions(policyQuery.data?.customerAccountId ?? ''),\n enabled: Boolean(policyQuery.data?.customerAccountId),\n });\n\n // ...\n}\n```\n\nThe `enabled` flag is the important part. It says: this query exists, but it should not run until its input exists.\n\nDon't put conditional hooks around this:\n\n```tsx\nif (policyQuery.data) {\n const documentsQuery = useQuery(...);\n}\n```\n\nReact hooks don't work that way. Keep the hook unconditional. Control execution with `enabled`.\n\n## Prefetch at the Route Boundary\n\nTanStack Router and TanStack Query fit together cleanly because route loaders can ensure query data before the page renders.\n\n```ts\n// src/pages/policy-detail/policy-detail.route.ts\nexport const policyDetailRoute = createRoute({\n getParentRoute: () => rootRoute,\n path: '/policies/$policyId',\n loader: ({ context, params }) =>\n context.queryClient.ensureQueryData(policyDetailQueryOptions(params.policyId)),\n component: PolicyDetailPage,\n});\n```\n\nThen inside the page:\n\n```tsx\nexport function PolicyDetailPage() {\n const { policyId } = Route.useParams();\n const policyQuery = useQuery(policyDetailQueryOptions(policyId));\n\n return <PolicyDetailView policy={policyQuery.data} />;\n}\n```\n\nThe page still uses `useQuery`. That is good. The loader warms the cache; the component subscribes to it. You get route-level loading behavior without inventing a second data path.\n\nFor list-to-detail navigation, prefetch on intent:\n\n```tsx\nfunction PolicyRow({ policy }: { policy: Policy }) {\n const queryClient = useQueryClient();\n\n return (\n <Link\n to=\"/policies/$policyId\"\n params={{ policyId: policy.id }}\n onMouseEnter={() => {\n queryClient.prefetchQuery(policyDetailQueryOptions(policy.id));\n }}\n >\n {policy.policyNumber}\n </Link>\n );\n}\n```\n\nHover is not the only signal. Focus works for keyboard users. Intersection works for visible rows. The point is the same: fetch before the click when intent is clear enough.\n\n## Mutations Should Invalidate Narrowly\n\nMutations deserve their own full article later, but invalidation belongs here.\n\nAfter editing a policy status:\n\n```ts\nexport function useEditPolicyStatus() {\n const queryClient = useQueryClient();\n\n return useMutation({\n mutationFn: updatePolicyStatus,\n onSuccess: (updatedPolicy) => {\n queryClient.setQueryData(\n policyKeys.detail(updatedPolicy.id),\n updatedPolicy,\n );\n\n queryClient.invalidateQueries({ queryKey: policyKeys.lists() });\n },\n });\n}\n```\n\nTwo things happen:\n\n1. The detail cache gets the exact updated policy immediately.\n2. Policy lists are invalidated because that status might affect filters, counts, and visible rows.\n\nI prefer this over invalidating everything:\n\n```ts\nqueryClient.invalidateQueries();\n```\n\nThat is the data-fetching equivalent of turning the lights off and on again. Sometimes useful during development. Rarely the right production behavior.\n\nBe specific. Your users can feel the difference.\n\n## Don't Mirror Query Data Into Local State\n\nThis is the smell I look for in reviews:\n\n```tsx\nconst policiesQuery = useQuery(policiesQueryOptions(filters));\nconst [policies, setPolicies] = useState<Policy[]>([]);\n\nuseEffect(() => {\n if (policiesQuery.data) {\n setPolicies(policiesQuery.data.items);\n }\n}, [policiesQuery.data]);\n```\n\nNow there are two sources of truth. Query cache and local state. They will drift.\n\nUsually the fix is simple:\n\n```tsx\nconst policies = policiesQuery.data?.items ?? [];\n```\n\nIf you need derived data, derive it:\n\n```tsx\nconst activePolicies = useMemo(\n () => policiesQuery.data?.items.filter((policy) => policy.status === 'active') ?? [],\n [policiesQuery.data?.items],\n);\n```\n\nIf you need editable draft state, make that explicit:\n\n```tsx\nconst policyQuery = useQuery(policyDetailQueryOptions(policyId));\nconst form = useForm({\n defaultValues: policyQuery.data,\n});\n```\n\nServer data can seed a draft. Once it becomes a draft, it is client state. Name the transition. Don't hide it in a `useEffect`.\n\n## The Review Checklist\n\nWhen I review TanStack Query code, I ask:\n\n- Does every query key include every variable used by the query function?\n- Are keys created through a factory instead of hand-written arrays everywhere?\n- Does each query have a deliberate `staleTime`?\n- Is pagination using `placeholderData` when flicker would hurt?\n- Are dependent queries controlled with `enabled`?\n- Are route loaders using the same query options as components?\n- Do mutations invali
1date the narrowest useful key?\n- Is query data being mirrored into local state unnecessarily?\n- Does the UI distinguish first load from background refetch?\n\nThat checklist catches most of the data bugs I see in React applications.\n\n## What Comes Next\n\nTanStack Query is the server-state layer. Once that layer is stable, routing becomes much easier to reason about: routes can preload the same query options, search params can drive query keys, and navigation can become type-safe instead of stringly typed.\n\n**Article #6:** [routing with TanStack Router](/blog/react-playbook-tanstack-router) â route trees, typed params, search validation, loaders, navigation, and how Router and Query should meet without turning every page into glue code.\n\n---\n\nThe haunted policy list eventually became boring. Not because we added more spinners. Because the data model became explicit. Query keys had hierarchy. Freshness was intentional. Mutations invalidated the right caches. Pagination stopped flickering. Route loaders warmed the detail page before the user landed there.\n\nThat is what I want from data fetching in React.\n\nNot cleverness. Calm.\n\nIf your React app has started to feel like the UI is arguing with the network, reach out. The fix is often not another loading state. It is giving server state one clear owner.\n"},{id:"react-playbook-error-handling",title:"Error Handling in React: What Users Should See When Things Break",excerpt:"Error handling is not a catch block sprinkled at the end. In a React + TanStack app, it is a product decision: what fails locally, what fails at the route level, what can retry, and what the user needs to do next.",tags:["React","Error Handling","TanStack","TypeScript","React Playbook"],date:"Mar 13, 2026",readTime:"12 min read",coverImage:"/blog/react-playbook-error-handling/cover.png",content:"\nThe first serious error-handling conversation on the insurance CRM happened after a demo.\n\nWe had built the policy detail page. It fetched the policy, loaded the customer, showed coverage sections, rendered a timeline, and pulled a list of recent changes. The happy path looked good. Fast enough. Clean enough. The kind of screen that makes a product manager relax for about five minutes.\n\nThen the staging API returned a 500 for the timeline.\n\nThe whole page disappeared.\n\nNot the timeline. Not the lower section. The entire policy detail page. A blank error screen replaced the policy number, the customer information, the coverage details, everything the agent actually needed to keep working.\n\nThe backend bug was real. But the frontend decision was worse: we had treated one failed widget as if the whole route was unusable.\n\nI've watched this happen in multiple React apps. Error handling gets added late, after the components are built and the data flow already exists. Someone wraps the app in an Error Boundary, adds a few `isError` checks, maybe logs to Sentry, and calls it done.\n\nIt is not done.\n\nError handling is not just catching failures. It is deciding the blast radius of a failure.\n\n> A good error boundary is not a wall around your app. It is a circuit breaker around the smallest thing that can safely fail.\n\nThis article continues the **React Playbook** foundation. We already covered the stack, TypeScript patterns, and code structure. Now we need to talk about what happens when the happy path breaks: React Error Boundaries, TanStack Query error states, TanStack Router route errors, mutation failures, and the messages users actually deserve.\n\n## First, Separate Error Types\n\nMost frontend error handling gets messy because teams put every failure in the same mental bucket.\n\nThey are not the same.\n\nIn a React app, I separate five categories:\n\n1. **Render errors** â a component throws while rendering.\n2. **Route loading errors** â a route cannot load the data required to show the page.\n3. **Query errors** â a specific async resource failed.\n4. **Mutation errors** â a user action failed.\n5. **Validation errors** â user input is invalid before the request should happen.\n\nEach one needs a different UX.\n\nA render error is usually unexpected. You need containment, logging, and a recovery path.\n\nA route loading error might mean the page cannot exist. If `/policies/$policyId` cannot load the policy at all, the page cannot show much.\n\nA query error might only affect part of the screen. If recent activity fails, the user can still read the policy.\n\nA mutation error happens after intent. The user clicked Save, Submit, Assign, Delete. The UI needs to explain what happened and preserve their work.\n\nA validation error is not exceptional at all. It is part of the form flow.\n\nWhen you model these separately, the code gets simpler. More importantly, the product gets calmer.\n\n## App-Level Error Boundary\n\nReact Error Boundaries catch errors thrown during rendering, lifecycle methods, and constructors of child components. They do not catch async errors from promises by default. TanStack Query and Router have their own integration points, which we'll get to.\n\nFor the app shell, you still want a top-level boundary:\n\n```tsx\n// src/app/AppErrorBoundary.tsx\nimport { ErrorBoundary } from 'react-error-boundary';\n\ninterface AppErrorFallbackProps {\n error: Error;\n resetErrorBoundary: () => void;\n}\n\nfunction AppErrorFallback({ error, resetErrorBoundary }
1: AppErrorFallbackProps) {\n return (\n <main className=\"mx-auto flex min-h-screen max-w-xl flex-col justify-center p-6\">\n <h1 className=\"text-2xl font-semibold\">Something went wrong</h1>\n <p className=\"mt-3 text-muted-foreground\">\n The application hit an unexpected error. You can try again, or refresh the page if the\n problem continues.\n </p>\n <pre className=\"mt-4 rounded-md bg-muted p-3 text-sm\">{error.message}</pre>\n <button className=\"mt-6\" onClick={resetErrorBoundary}>\n Try again\n </button>\n </main>\n );\n}\n\nexport function AppErrorBoundary({ children }: { children: React.ReactNode }) {\n return (\n <ErrorBoundary\n FallbackComponent={AppErrorFallback}\n onError={(error, info) => {\n reportError(error, { componentStack: info.componentStack });\n }}\n >\n {children}\n </ErrorBoundary>\n );\n}\n```\n\nThen wire it at the application edge:\n\n```tsx\n// src/app/providers.tsx\nimport { QueryClientProvider } from '@tanstack/react-query';\nimport { AppErrorBoundary } from './AppErrorBoundary';\nimport { queryClient } from './query-client';\n\nexport function AppProviders({ children }: { children: React.ReactNode }) {\n return (\n <AppErrorBoundary>\n <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>\n </AppErrorBoundary>\n );\n}\n```\n\nThis is the last line of defense. It should almost never be the boundary your users see during normal failures.\n\nIf a single chart crashes, don't replace the entire app. If a sidebar widget crashes, don't hide the working page. Put smaller boundaries around risky areas.\n\n## Section-Level Boundaries\n\nThe policy detail page had several independent sections:\n\n- policy header\n- coverage summary\n- beneficiaries\n- recent activity timeline\n- document attachments\n\nThe timeline was useful, but not essential. A timeline failure should not kill the route.\n\nSo give it a local boundary:\n\n```tsx\n// src/widgets/recent-policy-activity/RecentPolicyActivityBoundary.tsx\nimport { ErrorBoundary } from 'react-error-boundary';\nimport { RecentPolicyActivity } from './RecentPolicyActivity';\n\nfunction RecentPolicyActivityFallback() {\n return (\n <section className=\"rounded-md border p-4\">\n <h2 className=\"font-medium\">Recent activity</h2>\n <p className=\"mt-2 text-sm text-muted-foreground\">\n Activity could not be loaded right now. The policy details are still available.\n </p>\n </section>\n );\n}\n\nexport function RecentPolicyActivityBoundary({ policyId }: { policyId: string }) {\n return (\n <ErrorBoundary FallbackComponent={RecentPolicyActivityFallback}>\n <RecentPolicyActivity policyId={policyId} />\n </ErrorBoundary>\n );\n}\n```\n\nThe key sentence in the fallback is not \"An error occurred.\" The key sentence is: **the policy details are still available.**\n\nUsers need to know scope. What broke? What still works? What can they do next?\n\n## TanStack Query: Local Error States First\n\nTanStack Query gives you the query state directly:\n\n```tsx\nconst policyQuery = useQuery(policyDetailQueryOptions(policyId));\n\nif (policyQuery.isPending) return <PolicyDetailSkeleton />;\nif (policyQuery.isError) return <PolicyDetailError error={policyQuery.error} />;\n\nreturn <PolicyDetailView policy={policyQuery.data} />;\n```\n\nThis is the right pattern when the query owns the whole component. The component has one main job: load policy detail and render it. If that query fails, the component cannot do its job.\n\nMake the error component specific:\n\n```tsx\n// src/entities/policy/ui/PolicyDetailError.tsx\ninterface PolicyDetailErrorProps {\n error: Error;\n onRetry: () => void;\n}\n\nexport function PolicyDetailError({ error, onRetry }: PolicyDetailErrorProps) {\n return (\n <div className=\"rounded-md border border-destructive/30 p-4\">\n <h2 className=\"font-medium\">Policy could not be loaded</h2>\n <p className=\"mt-2 text-sm text-muted-foreground\">\n The policy may have been removed, or the server may be temporarily unavailable.\n </p>\n <p className=\"mt-2 text-xs text-muted-foreground\">{error.message}</p>\n <button className=\"mt-4\" onClick={onRetry}>\n Retry\n </button>\n </div>\n );\n}\n```\n\nThen pass the retry:\n\n```tsx\nif (policyQuery.isError) {\n return <PolicyDetailError error={policyQuery.error} onRetry={() => policyQuery.refetch()} />;\n}\n```\n\nThat `refetch()` matters. A dead error state with no recovery action forces the user to refresh the browser. That is not error handling. That is surrender.\n\n## Configure Query Defaults Deliberately\n\nIn article #1, we started with:\n\n```ts\n// src/app/query-client.ts\nimport { QueryClient } from '@tanstack/react-query';\n\nexport const queryClient = new QueryClient({\n defaultOptions: {\n queries: {\n staleTime: 1000 * 60 * 5,\n retry: 1,\n },\n },\n});\n```\n\n`retry: 1` is not random. The TanStack Query default is three retries with exponential backoff. For some apps, that is fine. For internal CRM workflows, I usually prefer one retry.\n\nWhy?\n\nBecause three retries can make an error feel like a frozen screen. The user clicked a policy. The API is failing. The UI waits, retries, waits, retries, waits again, and only then shows an error. Technically resilient. Product-wise frustrating.\n\nBut not every query deserves the same retry behavior.\n\n```ts\nexport function policyDetailQueryOptions(policyId: string) {\n return queryOptions({\n queryKey: policyKeys.detail(policyId),\n queryFn: () => fetchPolicyById(policyId),\n retry: (failureCount, error) => {\n if (isNotFoundError(error)) return false;\n return failureCount < 1;\n },\n });\n}\n```\n\nDon't retry 404s. The policy isn't going to appear after a second attempt. Do retry one transient network failure. That is a reasonable default.\n\nThis is where typed API errors help.\n\n## Typed API Errors\n\nIn article #2, we talked about typing API responses at the boundary. Do the same for errors.\n\n```ts\n// src/shared/api/api-error.ts\nex
1port class ApiError extends Error {\n constructor(\n message: string,\n public readonly status: number,\n public readonly code?: string,\n ) {\n super(message);\n this.name = 'ApiError';\n }\n}\n\nexport function isApiError(error: unknown): error is ApiError {\n return error instanceof ApiError;\n}\n\nexport function isNotFoundError(error: unknown) {\n return isApiError(error) && error.status === 404;\n}\n```\n\nUse it in the HTTP client:\n\n```ts\n// src/shared/api/http-client.ts\nimport { ApiError } from './api-error';\n\nexport async function getJson<T>(url: string): Promise<T> {\n const response = await fetch(url);\n\n if (!response.ok) {\n const body = await response.json().catch(() => null);\n throw new ApiError(body?.message ?? `GET ${url} failed`, response.status, body?.code);\n }\n\n return response.json() as Promise<T>;\n}\n```\n\nNow your UI can make product decisions:\n\n```tsx\nfunction PolicyDetailError({ error, onRetry }: PolicyDetailErrorProps) {\n if (isNotFoundError(error)) {\n return (\n <EmptyState\n title=\"Policy not found\"\n description=\"This policy may have been deleted or you may not have access to it.\"\n />\n );\n }\n\n return (\n <ErrorState\n title=\"Policy could not be loaded\"\n description=\"The server did not return the policy. Try again in a moment.\"\n action={{ label: 'Retry', onClick: onRetry }}\n />\n );\n}\n```\n\nA 404 and a 500 should not have the same message. One is probably permanent. One may be temporary. The UI should reflect that.\n\n## Route-Level Errors with TanStack Router\n\nSome data is required before a route can render.\n\nFor a policy detail page, the policy itself is required. Recent activity is not. Attachments are not. Recommendations are not.\n\nTanStack Router lets you load required data at the route level:\n\n```ts\n// src/pages/policy-detail/policy-detail.route.ts\nexport const policyDetailRoute = createRoute({\n getParentRoute: () => rootRoute,\n path: '/policies/$policyId',\n loader: ({ context, params }) =>\n context.queryClient.ensureQueryData(policyDetailQueryOptions(params.policyId)),\n errorComponent: PolicyDetailRouteError,\n component: PolicyDetailPage,\n});\n```\n\nThen the route error component owns route-level failures:\n\n```tsx\n// src/pages/policy-detail/PolicyDetailRouteError.tsx\nimport { useRouter } from '@tanstack/react-router';\nimport { isNotFoundError } from '@/shared/api';\n\nexport function PolicyDetailRouteError({ error }: { error: unknown }) {\n const router = useRouter();\n\n if (isNotFoundError(error)) {\n return (\n <RouteErrorLayout\n title=\"Policy not found\"\n description=\"The policy does not exist, was removed, or is not available to your account.\"\n action={{ label: 'Back to policies', to: '/policies' }}\n />\n );\n }\n\n return (\n <RouteErrorLayout\n title=\"Policy could not be opened\"\n description=\"Something failed while loading this policy. You can retry without leaving the page.\"\n action={{ label: 'Retry', onClick: () => router.invalidate() }}\n />\n );\n}\n```\n\n`router.invalidate()` is the route-level retry. It re-runs loaders and gives the route a chance to recover.\n\nThe important design choice is deciding what belongs in the loader. If every widget query goes into the loader, every widget failure becomes a route failure. That is exactly how we blanked the policy detail page in the demo.\n\nMy rule:\n\n> Load the minimum data required to make the route meaningful. Everything else should fail locally.\n\n## Mutation Errors Are Different\n\nQuery errors happen while reading.\n\nMutation errors happen after the user did something.\n\nThat difference matters. A failed mutation should preserve the user's input, explain what happened, and make retry obvious.\n\n```tsx\n// src/features/edit-policy-status/ui/EditPolicyStatusForm.tsx\nexport function EditPolicyStatusForm({ policyId }: { policyId: string }) {\n const editStatus = useEditPolicyStatus();\n\n return (\n <form\n onSubmit={(event) => {\n event.preventDefault();\n editStatus.mutate({ policyId, status: 'active' });\n }}\n >\n {editStatus.isError && (\n <InlineError>\n Status could not be updated. Your change was not saved. Try again in a moment.\n </InlineError>\n )}\n\n <button disabled={editStatus.isPending}>\n {editStatus.isPending ? 'Saving...' : 'Save status'}\n </button>\n </form>\n );\n}\n```\n\nNotice the wording: **Your change was not saved.**\n\nUsers need to know whether the action happened. Ambiguous mutation errors are dangerous. Did the payment submit? Did the policy update? Did the customer receive the email? If the system cannot guarantee success, say so clearly.\n\nFor destructive actions, be even more explicit:\n\n```tsx\n<InlineError>\n The document was not deleted. The server rejected the request, so it is still attached to the\n policy.\n</Inline
1Error>\n```\n\nThat kind of sentence sounds small. In operational software, it matters.\n\n## Optimistic Updates Need Rollback\n\nTanStack Query makes optimistic updates straightforward, but optimistic error handling has to be designed.\n\n```ts\n// src/features/edit-policy-status/api/edit-policy-status.mutation.ts\nexport function useEditPolicyStatus() {\n const queryClient = useQueryClient();\n\n return useMutation({\n mutationFn: updatePolicyStatus,\n onMutate: async (variables) => {\n await queryClient.cancelQueries({ queryKey: policyKeys.detail(variables.policyId) });\n\n const previousPolicy = queryClient.getQueryData<PolicyDetail>(\n policyKeys.detail(variables.policyId),\n );\n\n queryClient.setQueryData<PolicyDetail>(policyKeys.detail(variables.policyId), (policy) =>\n policy ? { ...policy, status: variables.status } : policy,\n );\n\n return { previousPolicy };\n },\n onError: (_error, variables, context) => {\n queryClient.setQueryData(policyKeys.detail(variables.policyId), context?.previousPolicy);\n },\n onSettled: (_data, _error, variables) => {\n queryClient.invalidateQueries({ queryKey: policyKeys.detail(variables.policyId) });\n },\n });\n}\n```\n\nOptimistic UI is a promise: \"we'll show the result now and clean it up if the server disagrees.\"\n\nIf you don't roll back, it becomes a lie.\n\nHonestly, I use optimistic updates less often than people expect. They are excellent for low-risk interactions: toggles, lightweight status changes, local ordering. For payments, legal records, irreversible actions, or anything with compliance implications, I prefer boring pending states and clear confirmation.\n\nFast is good. Correct is better.\n\n## Validation Errors Are Not Exceptions\n\nForm validation errors should not go through Error Boundaries, route error components, or global toasts.\n\nThey belong next to the field or section that needs correction.\n\n```tsx\n// src/features/create-policy/ui/CreatePolicyForm.tsx\n<form.Field\n name=\"holderName\"\n validators={{\n onBlur: ({ value }) => {\n if (!value.trim()) return 'Policy holder name is required';\n return undefined;\n },\n }}\n>\n {(field) => (\n <TextField\n label=\"Policy holder\"\n value={field.state.value}\n onBlur={field.handleBlur}\n onChange={(event) => field.handleChange(event.target.value)}\n error={field.state.meta.errors[0]}\n />\n )}\n</form.Field>\n```\n\nA validation message is not an apology. It is guidance.\n\nBad:\n\n```\nInvalid value.\n```\n\nBetter:\n\n```\nPolicy holder name is required.\n```\n\nBetter still, when the rule is domain-specific:\n\n```\nCoverage start date must be after the policy approval date.\n```\n\nThe closer the error is to the user's mental model, the less support burden you create.\n\n## Global Toasts Are Not an Error Strategy\n\nI have a strong bias here: global error toasts are overused.\n\nThey are fine for background actions:\n\n```\nReport export failed. Try again later.\n```\n\nThey are not fine as the only feedback for a failed form submit, failed route load, or failed section query.\n\nToasts disappear. They are hard to reread. They often don't say what changed. If the user needs the information to continue, put it in the interface where the failure happened.\n\nUse toasts for secondary notifications. Use inline states for primary failures.\n\n## Logging Without Leaking\n\nYou still need error reporting.\n\nBut don't send everything everywhere. A useful report has context:\n\n```ts\n// src/shared/lib/report-error.ts\ninterface ErrorContext {\n route?: string;\n policyId?: string;\n componentStack?: string;\n action?: string;\n}\n\nexport function reportError(error: unknown, context: ErrorContext = {}) {\n if (import.meta.env.DEV) {\n console.error(error, context);\n return;\n }\n\n // Send to your reporting tool here.\n // Include context, but avoid sensitive policy/customer data.\n}\n```\n\nFor a CRM, this matters. Policy numbers, customer names, payment details, medical information, internal notes â these should not casually land in logs because a component threw.\n\nLog identifiers when needed. Avoid payloads unless you have a reason and a retention policy.\n\n## A Practical Error-Handling Checklist\n\nWhen I review a React screen, I ask these questions:\n\n- What data is required for the route to be meaningful?\n- What data can fail locally without killing the page?\n- Does every failed query have a retry path?\n- Does every failed mutation preserve user input?\n- Does the user know
1whether their action was saved?\n- Are 404, 403, 409, and 500 treated differently where it matters?\n- Are validation errors close to the fields?\n- Are unexpected errors logged with useful context and without sensitive data?\n\nThis is not bureaucracy. This is how you keep production software calm.\n\n## What Comes Next\n\nThe first four articles now cover the base layer of a serious React application:\n\n- **Article #1:** [the stack](/blog/react-playbook-starting-new-project)\n- **Article #2:** [TypeScript patterns](/blog/react-playbook-typescript-patterns)\n- **Article #3:** [code structure](/blog/react-playbook-code-structure)\n- **Article #4:** this error-handling model\n\n**Article #5:** [data fetching with TanStack Query](/blog/react-playbook-data-fetching) â query keys, cache shape, prefetching, invalidation, stale time, pagination, and the boring details that decide whether a React app feels reliable or twitchy.\n\n---\n\nAfter that staging demo, we changed the policy detail page. The policy itself stayed route-level. The timeline moved into a local query with its own fallback. Attachments got a retry button. Mutations kept user input visible after failure. The app did not become immune to backend problems. No frontend can do that.\n\nBut failures became smaller.\n\nThat is the whole point. Not hiding errors. Not pretending the system is fine. Making sure one broken piece does not take the rest of the work down with it.\n\nIf you're designing error handling for a React app and every failure currently becomes either a toast or a blank screen, reach out. There is usually a calmer architecture hiding one boundary lower.\n"},{id:"react-playbook-code-structure",title:"Code Structure in React: The FSD Version I Actually Use",excerpt:"Flat folders are fine until every feature starts touching routes, queries, forms, permissions, and UI state. Here's the React structure I use when a TanStack app grows past the simple stage without turning FSD into folder theater.",tags:["React","Architecture","FSD","TanStack","React Playbook"],date:"Mar 10, 2026",readTime:"12 min read",coverImage:"/blog/react-playbook-code-structure/cover.png",content:"\nAt the insurance company, our React app started with a structure I still like for small projects.\n\n```\nsrc/\n api/\n components/\n hooks/\n layouts/\n pages/\n types/\n router.ts\n main.tsx\n```\n\nFor the first few months, it worked. A policy list page lived in `pages/`. Shared buttons lived in `components/`. Fetch functions lived in `api/`. Everyone understood where things went.\n\nThen the policy builder arrived.\n\nIt was not a page anymore. It was a product inside the product: dynamic fields, conditional sections, autosave, permissions, validation, optimistic updates, draft recovery, and analytics. Suddenly one change touched `pages/`, `api/`, `types/`, `hooks/`, `components/`, and a couple of files nobody remembered creating.\n\nI've seen this pattern across multiple teams. A flat structure doesn't fail all at once. It fails quietly. First you add one more shared hook. Then one more domain type. Then a generic component that is not generic at all, just shared too early. Six months later the folders are still clean, but every feature is smeared across the project.\n\nThat is usually the moment people reach for Feature-Sliced Design.\n\nHonestly, I think that instinct is right. FSD gives React projects a language for scale: layers, slices, public APIs, dependency direction. But if you apply it mechanically, it creates a different problem. The code becomes \"architecturally correct\" and painful to work with.\n\nThis article is the version I actually use in production React apps. Not pure FSD. Not flat folders forever. A practical structure for TanStack Query, TanStack Router, TanStack Form, and the kind of domain logic that appears in real applications.\n\n> Architecture should make the next change easier to locate, not just easier to categorize.\n\n## Start Flat, Then Move When Pain Appears\n\nI don't start every React app with full FSD.\n\nFor a new Vite + TanStack project, this is still a good beginning:\n\n```\nsrc/\n api/\n policies.ts\n query-keys.ts\n components/\n Button.tsx\n DataList.tsx\n layouts/\n RootLayout.tsx\n pages/\n PolicyListPage.tsx\n PolicyDetailPage.tsx\n types/\n policy.ts\n router.ts\n main.tsx\n```\n\nThis structure is boring. Boring is good at the start.\n\nThe mistake is keeping it after the app stops being boring. You don't need FSD because the project has ten f
1iles. You need it when features become independent units of change.\n\nThe signal is not folder count. The signal is change pattern.\n\nIf every policy-related change touches five top-level folders, the structure is telling you something. If onboarding a developer requires explaining \"policy things are in several places, you'll get used to it,\" the structure is telling you something. If a file is in `shared/` because two components use it but both components belong to the same feature, the structure is telling you something.\n\nAt that point, I move to a sliced structure.\n\n## The Structure\n\nThe shape I use most often:\n\n```\nsrc/\n app/\n router.ts\n providers.tsx\n query-client.ts\n pages/\n policy-list/\n PolicyListPage.tsx\n route.ts\n policy-detail/\n PolicyDetailPage.tsx\n route.ts\n widgets/\n policy-search-panel/\n policy-summary-card/\n features/\n create-policy/\n edit-policy-status/\n assign-policy-owner/\n entities/\n policy/\n api/\n model/\n ui/\n lib/\n index.ts\n customer/\n api/\n model/\n ui/\n index.ts\n shared/\n api/\n config/\n lib/\n ui/\n```\n\nThe names come from FSD, but the goal is not to worship the names. The goal is to separate code by responsibility and change frequency.\n\n**`app/`** is application wiring. Providers, router creation, query client configuration, global error boundaries. It should know how the app starts. It should not know how policy validation works.\n\n**`pages/`** are route-level screens. They compose widgets, features, and entities. They should be thin. If a page file becomes a business logic container, something probably belongs lower.\n\n**`widgets/`** are larger UI blocks made from several domain pieces. A search panel, a dashboard section, a header with account controls.\n\n**`features/`** are user actions. Create policy. Update status. Assign owner. Submit payment method.\n\n**`entities/`** are domain concepts. Policy. Customer. Beneficiary. Claim. This is where domain types, query keys, API functions, selectors, and reusable entity UI usually live.\n\n**`shared/`** is for things that are truly domain-agnostic: buttons, date formatting, HTTP client, environment config, tiny utilities.\n\nThe import direction matters:\n\n```\napp -> pages -> widgets -> features -> entities -> shared\n```\n\nLower layers don't import higher layers. `entities/policy` should not import from `features/create-policy`. `shared/ui/Button` should not know a policy exists.\n\nThat one rule prevents a lot of architectural rot.\n\n## Where TanStack Query Goes\n\nThe most common question in this stack: where do query hooks live?\n\nMy default: query keys and fetch functions live in the entity. Feature-specific mutations live in the feature if they represent a user action.\n\n```\nsrc/\n entities/\n policy/\n api/\n policy.api.ts\n policy.queries.ts\n model/\n policy.types.ts\n index.ts\n features/\n edit-policy-status/\n api/\n edit-policy-status.mutation.ts\n ui/\n EditPolicyStatusButton.tsx\n index.ts\n```\n\nThe entity owns the stable data contract:\n\n```ts\n// src/entities/policy/api/policy.queries.ts\nimport { queryOptions } from '@tanstack/react-query';\nimport { fetchPolicyById, fetchPolicies } from './policy.api';\n\nexport const policyKeys = {\n all: ['policies'] as const,\n lists: () => [...policyKeys.all, 'list'] as const,\n list: (filters: PolicyListFilters) => [...policyKeys.lists(), filters] as const,\n details: () => [...policyKeys.all, 'detail'] as const,\n detail: (policyId: string) => [...policyKeys.details(), policyId] as const,\n};\n\nexport function policiesQueryOptions(filters: PolicyListFilters) {\n return queryOptions({\n queryKey: policyKeys.list(filters),\n queryFn: () => fetchPolicies(filters),\n });\n}\n\nexport function policyDetailQueryOptions(policyId: string) {\n return queryOptions({\n queryKey: policyKeys.detail(policyId),\n queryFn: () => fetchPolicyById(policyId),\n });\n}\n```\n\nThe feature owns the action:\n\n```ts\n// src/features/edit-policy-status/api/edit-policy-status.mutation.ts\nimport { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { policyKeys } from '@/entities/policy';\nimport { updatePolicyStatus } from './edit-policy-status.api';\n\nexport function useEditPolicyStatus() {\n const queryClient = useQueryClient();\n\n return useMutation({\n mutationFn: updatePolicyStatus,\n onSuccess: (_, variables) =>
1 {\n queryClient.invalidateQueries({ queryKey: policyKeys.detail(variables.policyId) });\n queryClient.invalidateQueries({ queryKey: policyKeys.lists() });\n },\n });\n}\n```\n\nThis keeps the split honest. Reading policy data is part of the policy entity. Editing a status is a user action, so it belongs to a feature.\n\nCould you put all mutations inside `entities/policy`? Sometimes, yes. For simple CRUD apps it works. But once mutations include permissions, analytics, form state, toasts, optimistic updates, or workflow-specific behavior, I prefer feature ownership. The code changes with the feature, so it should live with the feature.\n\n## Where TanStack Router Goes\n\nTanStack Router adds an interesting pressure: route definitions are code, and code wants to be near the page it renders.\n\nFor small apps, one `src/router.ts` is fine. For larger apps, I split route pieces by page and compose them in `app/router.ts`.\n\n```\nsrc/\n app/\n router.ts\n pages/\n policy-list/\n PolicyListPage.tsx\n policy-list.route.ts\n policy-detail/\n PolicyDetailPage.tsx\n policy-detail.route.ts\n```\n\n```ts\n// src/pages/policy-detail/policy-detail.route.ts\nimport { createRoute } from '@tanstack/react-router';\nimport { rootRoute } from '@/app/root-route';\nimport { policyDetailQueryOptions } from '@/entities/policy';\nimport { PolicyDetailPage } from './PolicyDetailPage';\n\nexport const policyDetailRoute = createRoute({\n getParentRoute: () => rootRoute,\n path: '/policies/$policyId',\n loader: ({ context, params }) =>\n context.queryClient.ensureQueryData(policyDetailQueryOptions(params.policyId)),\n component: PolicyDetailPage,\n});\n```\n\nThe page route can import from entities because pages sit above entities. The entity must never import the route.\n\nThis also makes preloading natural. The route knows it needs a policy detail. The entity knows how to describe that query. TanStack Router and TanStack Query meet at the page boundary instead of leaking through the whole application.\n\n## Where TanStack Form Goes\n\nForms are where clean architecture usually goes to suffer.\n\nA form is not just UI. It has schema, default values, validation, submission, dirty state, field components, and often domain-specific rules. If you split those across `shared/forms/`, `entities/`, `features/`, and `pages/` too early, every form change becomes a small hunt.\n\nMy rule:\n\n> Put the form where the user action lives. Extract only the parts that are genuinely reused by another action.\n\nFor creating a policy:\n\n```\nsrc/\n features/\n create-policy/\n model/\n create-policy.schema.ts\n create-policy.types.ts\n api/\n create-policy.mutation.ts\n ui/\n CreatePolicyForm.tsx\n CoverageSection.tsx\n BeneficiariesSection.tsx\n index.ts\n```\n\nThe feature owns the form because the form exists to perform the feature.\n\nIf `CoverageSection` later appears in three different policy workflows, then move it. Not before. Premature sharing is one of the fastest ways to make React code harder to change.\n\nThis is especially important with TanStack Form because field-level logic tends to be close to business rules:\n\n```tsx\n// src/features/create-policy/ui/CreatePolicyForm.tsx\nimport { useForm } from '@tanstack/react-form';\nimport { createPolicySchema } from '../model/create-policy.schema';\nimport { useCreatePolicy } from '../api/create-policy.mutation';\n\nexport function CreatePolicyForm() {\n const createPolicy = useCreatePolicy();\n\n const form = useForm({\n defaultValues: {\n holder
1Name: '',\n coverageType: 'standard',\n beneficiaries: [],\n },\n validators: {\n onSubmit: createPolicySchema,\n },\n onSubmit: ({ value }) => createPolicy.mutate(value),\n });\n\n return (\n <form\n onSubmit={(event) => {\n event.preventDefault();\n form.handleSubmit();\n }}\n >\n {/* fields */}\n </form>\n );\n}\n```\n\nThe schema, mutation, and UI are close because they change together. That is not messy. That is locality.\n\n## What Belongs in Shared\n\n`shared/` is the most abused folder in React architecture.\n\nIf a file feels \"not specific enough,\" people put it in shared. Then shared becomes the new junk drawer: domain helpers, half-generic hooks, components with policy-specific props, validation rules from one workflow, and API helpers that know too much.\n\nI use a stricter test.\n\nA file belongs in `shared/` only if it can be moved to a different product in the same company and still make sense.\n\nGood candidates:\n\n```\nshared/\n api/\n http-client.ts\n config/\n env.ts\n lib/\n format-date.ts\n assert-never.ts\n ui/\n Button.tsx\n Dialog.tsx\n TextField.tsx\n```\n\nBad candidates:\n\n```\nshared/\n hooks/\n usePolicyValidation.ts\n lib/\n calculateCoveragePremium.ts\n ui/\n PolicyStatusBadge.tsx\n```\n\nThose are not shared. They are policy code. Put them in `entities/policy` or in the feature that owns the workflow.\n\nThis one discipline keeps FSD useful. When shared stays small, the rest of the architecture stays readable.\n\n## Public APIs: The Boring Part That Matters\n\nEvery slice should expose a public API through `index.ts`.\n\n```\nsrc/entities/policy/index.ts\n```\n\n```ts\nexport type { Policy, PolicyDetail, PolicyStatus } from './model/policy.types';\nexport { policyKeys, policyDetailQueryOptions, policiesQueryOptions } from './api/policy.queries';\nexport { PolicyStatusBadge } from './ui/PolicyStatusBadge';\n```\n\nThen other layers import from the slice root:\n\n```ts\nimport { PolicyStatusBadge, policyDetailQueryOptions } from '@/entities/policy';\n```\n\nNot from internal paths:\n\n```ts\nimport { policyDetailQueryOptions } from '@/entities/policy/api/policy.queries';\n```\n\nThe public API gives you a boundary. You can reorganize internals without updating imports across the whole app. It also forces a small moment of intent: is this thing actually meant to be used outside the slice?\n\nThat question catches more design mistakes than people expect.\n\n## The Rule I Break on Purpose\n\nPure FSD can become too granular for real feature work. I break one rule deliberately: I colocate deeply related feature files even when they could be classified into separate layers.\n\nIf `CreatePolicyForm`, `createPolicySchema`, `createPolicyMutation`, and `CoverageSection` always change together, I keep them in `features/create-policy`.\n\nI don't move `CoverageSection` to `widgets/` because it looks like a block.\n\nI don't move `createPolicySchema` to `entities/policy` because it mentions policy fields.\n\nI don't move a tiny helper to `shared/lib` because two files in the same feature use it.\n\nThis is not laziness. It is a design choice.\n\nI've been in the room where a team spent twenty minutes arguing whether a file was a feature, an entity, or a widget. Different company, different stack, same argument. At some point, the question stops being architectural and starts being clerical.\n\nWhen that happens, I ask a simpler question: **what changes with this file?**\n\nIf the answer is \"this feature,\" it stays in the feature.\n\n## A Migration Path That Doesn't Hurt\n\nYou don't need to restructure the entire app in one PR.\n\nStart with one domain that already hurts. In the insurance CRM, that would have been policies.\n\nBefore:\n\n```\nsrc/\n api/policies.ts\n components/PolicyStatusBadge.tsx\n hooks/usePolicyFilters.ts\n pages/PolicyListPage.tsx\n pages/PolicyDetailPage.tsx\n types/policy.ts\n```\n\nAfter:\n\n```\nsrc/\n entities/\n policy/\n api/\n policy.api.ts\n policy.queries.ts\n model/\n policy.types.ts\n ui/\n PolicyStatusBadge.tsx\n index.ts\n pages/\n policy-list/\n PolicyListPage.tsx\n policy-detail/\n PolicyDetailPage.tsx\n```\n\nThen move the next workflow into `features/` when you touch it for real work.\n\nDon't create empty folders for future architecture. Empty architecture is theater. Let structure follow actual code.\n\n## How I Know the Structure Is Working\n\nGood structure has practical symptoms.\n\nA new feature starts in one folder. A domain type is easy to find. Query invalidation is predictable. Shared stays boring. A page reads like composition instead of orchestration.\n\nMore importantly, changes have shape.\n\nWhen a status-editing bug appears, you know to open `features/edit-policy-status`. When a policy response changes, you know to open `entities/policy`. When route preloading fails, you know to look at the page route.\n\nThat is the payoff. Not prettier folders. Faster orientation.\n\n## What Comes Next\n\nThe first three articles now give us the foundation:\n\n- **Article #1:** [the starting stack](/blog/react-playbook-starting-new-project) â Vite, TanStack Query, Router, Form, and the minimal setup\n- **Article #2:** [TypeScript patterns](/blog/react-playbook-typescript-patterns) â typed API boundaries, query keys, route params, async state\n- **Article #3:** this
1structure â how those pieces live together once the app grows\n\n**Article #4:** [error handling](/blog/react-playbook-error-handling) â Error Boundaries, TanStack Query error states, route-level failures, and what to show users when the happy path breaks.\n\n---\n\nThat policy builder eventually became one of the busiest parts of the insurance CRM. The structure was not perfect. No structure is. But after we moved policy code into a domain slice and kept workflow-specific logic inside features, the app became easier to reason about.\n\nNot because FSD solved architecture for us. Because it gave us enough structure to stop guessing, and enough flexibility to keep related code close.\n\nThat's the version I trust: architecture as a working agreement, not a folder ceremony.\n\nIf you're trying to move a React codebase from flat folders into something more deliberate, feel free to reach out. The hard part is rarely naming the folders. The hard part is deciding what should change together.\n"},{id:"react-playbook-typescript-patterns",title:"TypeScript in React: Patterns That Actually Matter",excerpt:"Not a TypeScript tutorial. A focused look at the patterns that eliminate real production bugs in React + TanStack apps â typed API layers, discriminated unions for async state, route param inference, and generic components done right.",tags:["React","TypeScript","TanStack","React Playbook"],date:"Mar 07, 2026",readTime:"11 min read",content:"\nSix weeks into the insurance CRM project, we had a production incident. The backend team renamed a field â `policy_number` became `policyNumber` â as part of a snake_case-to-camelCase API migration. The migration happened on a Friday. By Monday morning, the policy list was blank for every user.\n\nThe bug took four minutes to find. The cause took longer to accept: we had typed the API response as `any`. Not carelessly â the original developer had written a comment: *\"TODO: add proper types.\"* The TODO was six months old.\n\nHonest answer: I've seen this exact scenario more times than I want to count. TypeScript installed, TypeScript ignored at the boundaries. `any` at the API layer, loose props in the middle, and full type safety only in the parts that didn't need it â utility functions, helpers, things that already worked.\n\nThis article is not a TypeScript tutorial. If you need to understand generics from first principles, there are better resources for that. What I want to cover are the specific patterns that prevent real production bugs in React + TanStack apps. The ones I reach for on every project now, not as best practices, but because I've seen what happens without them.\n\n## Pattern 1: The Typed API Layer\n\nThe most important rule: **type your API responses at the boundary, once.**\n\nNot in the component. Not in the hook. At the fetch function that talks to the API. Everything downstream inherits the types automatically.\n\n```ts\n// src/api/policies.ts\nimport type { Policy, PolicyDetail } from '../types/policy';\n\nexport async function fetchPolicies(): Promise<Policy[]> {\n const res = await fetch('/api/policies');\n if (!res.ok) throw new Error(`GET /api/policies failed: ${res.status}`);\n return res.json() as Promise<Policy[]>;\n}\n\nexport async function fetchPolicyById(policyId: string): Promise<PolicyDetail> {\n const res = await fetch(`/api/policies/${policyId}`);\n if (!res.ok) throw new Error(`GET /api/policies/${policyId} failed: ${res.status}`);\n return res.json() as Promise<PolicyDetail>;\n}\n```\n\n```ts\n// src/types/policy.ts\nexport interface Policy {\n id: string;\n policyNumber: string;\n holderName: string;\n status: 'active' | 'expired' | 'pending';\n effectiveDate: string;\n}\n\nexport interface PolicyDetail extends Policy {\n coverageType: string;\n premiumAmount: number;\n beneficiaries: Beneficiary[];\n}\n\nexport interface Beneficiary {\n id: string;\n name: string;\n relationship: string;\n percentage: number;\n}\n```\n\nNow `useQuery({ queryFn: fetchPolicies })` returns `Policy[]` â typed, autocompleted, checked at every usage. When the backend changes `policyNumber` to something else, TypeScript tells you *before* production.\n\nThe `as Promise<Policy[]>
1` cast on `res.json()` is intentional. `res.json()` returns `Promise<unknown>` in recent TypeScript. We're making an explicit contract: *this fetch function guarantees this shape.* If you want runtime validation as well, add Zod:\n\n```ts\nimport { z } from 'zod';\n\nconst PolicySchema = z.object({\n id: z.string(),\n policyNumber: z.string(),\n holderName: z.string(),\n status: z.enum(['active', 'expired', 'pending']),\n effectiveDate: z.string(),\n});\n\nexport async function fetchPolicies(): Promise<Policy[]> {\n const res = await fetch('/api/policies');\n if (!res.ok) throw new Error(`GET /api/policies failed: ${res.status}`);\n const raw = await res.json();\n return z.array(PolicySchema).parse(raw);\n}\n```\n\nWith Zod, a field rename or unexpected shape becomes a runtime error with a descriptive message, not a blank screen with no useful stack trace. Article #4 covers error handling strategies â for now, know this option exists.\n\n## Pattern 2: Query Key Factories\n\nQuery keys in TanStack Query are arrays. They look harmless until you have fifty queries and someone types `['policy', policyId]` in one file and `['policies', id]` in another. Invalidation stops working silently.\n\nThe fix is a query key factory â one object that owns all keys for a domain:\n\n```ts\n// src/api/query-keys.ts\nexport const policyKeys = {\n all: ['policies'] as const,\n lists: () => [...policyKeys.all, 'list'] as const,\n list: (filters: PolicyListFilters) => [...policyKeys.lists(), filters] as const,\n details: () => [...policyKeys.all, 'detail'] as const,\n detail: (id: string) => [...policyKeys.details(), id] as const,\n};\n```\n\nUsage:\n\n```ts\n// In PolicyListPage\nuseQuery({\n queryKey: policyKeys.list({ status: 'active' }),\n queryFn: () => fetchPolicies({ status: 'active' }),\n});\n\n// In mutation after update\nqueryClient.invalidateQueries({ queryKey: policyKeys.lists() });\n\n// In PolicyDetailPage\nuseQuery({\n queryKey: policyKeys.detail(policyId),\n queryFn: () => fetchPolicyById(policyId),\n});\n```\n\nThe `as const` assertions make the arrays readonly tuples instead of `(string | PolicyListFilters)[]`, which means TypeScript knows the exact structure. When you call `invalidateQueries({ queryKey: policyKeys.lists() })`, it correctly invalidates all list queries regardless of their filter arguments, because the key hierarchy matches.\n\nThis is a pattern I keep coming back to. Centralizing keys also makes it trivial to audit which queries exist in an application â you read one file.\n\n## Pattern 3: Discriminated Unions for Async State\n\nThe classic approach to async state in React:\n\n```tsx\nconst [data, setData] = useState(null);\nconst [loading, setLoading] = useState(false);\nconst [error, setError] = useState(null);\n```\n\nThis is three independent booleans representing one state machine. The problem: you can have `loading: true` and `data: something` at the same time. TypeScript can't tell you that's impossible. Your component has to handle combinations that shouldn't exist.\n\nTanStack Query solves this with its own state machine, but when you need custom async state â or when you want to model UI states explicitly â discriminated unions are the right tool:\n\n```ts\n// src/types/async-state.ts\nexport type AsyncState<T> =\n | { status: 'idle' }\n | { status: 'loading' }\n | { status: 'error'; error: Error }\n | { status: 'success'; data: T };\n```\n\nUsage in a component:\n\n```tsx\nfunction PolicyDetail({ policyId }: { policyId: string }) {\n const query = useQuery({\n queryKey: policyKeys.detail(policyId),\n queryFn: () => fetchPolicyById(policyId),\n });\n\n if (query.isPending) return <PolicyDetailSkeleton />;\n if (query.isError) return <ErrorMessage error={query.error} />;\n\n // TypeScript now knows query.data is PolicyDetail â not PolicyDetail | undefined\n const policy = query.data;\n\n return (\n <div>\n <h1>{policy.policyNumber}</h1>\n <p>{policy.holderName}</p>\n </div>\n );\n}\n```\n\nAfter the `isError` guard, TypeScript narrows `query.data` to the non-undefined type. This is TanStack Query's built-in discriminated union working correctly. The principle applies when you build your own async state machines: model them as discriminated unions from the start, and TypeScript narrows automatically when you check `status`.\n\n> TypeScript's type narrowing is only as good as your state model. If your state model allows impossible combinations, no amount of type annotations fixes that.\n\n## Pattern 4: Typed Route Params with TanStack Router\n\nThis is where TanStack Router pays for itself. In article #1, we set up the router with a `Register` declaration. Here's what that gives you:\n\n```tsx\n// src/pages/PolicyDetailPage.tsx\nimport { useParams } from '@tanstack/react-router';
1\n\nexport function PolicyDetailPage() {\n // policyId is string â typed against the route definition\n // TypeScript error if this route doesn't have $policyId\n const { policyId } = useParams({ from: '/policies/$policyId' });\n\n // ...\n}\n```\n\nCompare to React Router:\n\n```tsx\n// React Router â manual cast, no safety\nconst { policyId } = useParams<{ policyId: string }>();\n```\n\nThe React Router version compiles even if the route doesn't exist. The TanStack Router version fails to compile if the route doesn't have `$policyId`. That's the difference between type annotations and type inference.\n\nSearch params work the same way, but you need to define a validator on the route:\n\n```ts\n// src/router.ts\nimport { z } from 'zod';\n\nconst policyListSearchSchema = z.object({\n status: z.enum(['active', 'expired', 'pending']).optional(),\n page: z.number().int().positive().optional().default(1),\n});\n\nconst policyListRoute = createRoute({\n getParentRoute: () => rootRoute,\n path: '/policies',\n validateSearch: policyListSearchSchema,\n component: PolicyListPage,\n});\n```\n\n```tsx\n// In PolicyListPage\nconst { status, page } = useSearch({ from: '/policies' });\n// status: 'active' | 'expired' | 'pending' | undefined\n// page: number (default 1)\n```\n\nSearch params validated with Zod, typed at the component level, consistent across navigation. No `URLSearchParams` parsing. No manual type assertions. Navigating to `/policies?status=invalid` either throws at the router level or falls back to the default â you control the behavior.\n\n## Pattern 5: Generic Data Components\n\nReact components that render lists of things are everywhere. The naive approach writes a new component for every list. The slightly better approach passes `items` as `any[]`. The right approach uses generics:\n\n```tsx\n// src/components/DataList.tsx\ninterface DataListProps<T> {\n items: T[];\n getKey: (item: T) => string;\n renderItem: (item: T) => React.ReactNode;\n emptyMessage?: string;\n}\n\nexport function DataList<T>({\n items,\n getKey,\n renderItem,\n emptyMessage = 'No items found',\n}: DataListProps<T>) {\n if (items.length === 0) {\n return <p className=\"text-muted-foreground\">{emptyMessage}</p>;\n }\n\n return (\n <ul className=\"space-y-2\">\n {items.map((item) => (\n <li key={getKey(item)}>{renderItem(item)}</li>\n ))}\n </ul>\n );\n}\n```\n\nUsage:\n\n```tsx\n<DataList\n items={policies}\n getKey={(p) => p.id}\n renderItem={(p) => <PolicyRow policy={p} />}\n emptyMessage=\"No policies match this filter\"\n/>\n```\n\nTypeScript infers `T` as `Policy` from the `items` prop. The `getKey` and `renderItem` functions are typed to `Policy` automatically. You can't pass a `Beneficiary` to `renderItem` by accident.\n\nThis pattern works for any container component: tables, grids, selects, autocompletes. Write it once, use it everywhere, get full type safety for free.\n\n## Pattern 6: Typed Context\n\nReact Context is commonly the place where types collapse to `any` or to `T | undefined` that requires a null check at every usage. A cleaner pattern:\n\n```ts\n// src/context/auth-context.tsx\ninterface AuthContextValue {\n userId: string;\n role: 'admin' | 'agent' | 'viewer';\n logout: () => void;\n}\n\nconst AuthContext = React.createContext<AuthContextValue | null>(null);\n\nexport function useAuth(): AuthContextValue {\n const ctx = React.useContext(AuthContext);\n if (!ctx) throw new Error('useAuth must be used within AuthProvider');\n return ctx;\n}\n```\n\nThe context itself accepts `null` (before the provider mounts). The `useAuth` hook throws if used outside the provider and returns the non-null type. Every component that calls `useAuth()` gets `AuthContextValue` without optional chaining. The invariant is checked once, at the hook boundary.\n\nThis is not a new pattern. It's in the React docs. But I keep seeing codebases where context values are `T | undefined` and components are littered with `auth?.userId ?? ''`. The throw-in-the-hook approach pushes the validation to the right place and cleans up every callsite.\n\n## What to Carry Forward\n\nThese six patterns cover the majority of TypeScript friction I encounter in React codebase
1s:\n\n- **Typed API layer** â type once at the boundary, inherit everywhere\n- **Query key factories** â centralize keys, prevent invalidation bugs\n- **Discriminated unions** â model states correctly, let TypeScript narrow\n- **TanStack Router inference** â route params typed against actual routes, not manual casts\n- **Generic components** â one implementation, full type safety across all usages\n- **Typed context with throwing hooks** â clean callsites, invariant checked once\n\nNone of these require advanced TypeScript knowledge. They require the discipline to type at boundaries instead of in the middle, and to model state as what it actually is rather than what's convenient.\n\n---\n\nSix months after that production incident, the insurance CRM had a typed API layer across every endpoint and Zod validation on the most critical ones. We had two more backend field renames during that time. Neither reached production. TypeScript caught both during development, before the PRs were even reviewed.\n\nThat's the whole point â not type coverage as a metric, but fewer incidents as an outcome.\n\nIf you're working through TypeScript patterns in a React codebase and hitting specific friction points â reach out. These problems are usually more mechanical than they appear.\n"},{id:"react-playbook-starting-new-project",title:"Starting a New React Project in 2026-2027: The Stack I Keep Coming Back To",excerpt:"Every new React project starts with the same question: which tools? After years of watching teams assemble mismatched libraries that fight each other, I stopped choosing independently. This is the coherent stack I now use by default â and why.",tags:["React","TanStack","TypeScript","React Playbook"],date:"Mar 03, 2026",readTime:"12 min read",content:"\nI was three weeks into a new client engagement â a mid-sized insurance company building a policy administration CRM from scratch. No legacy code, no inherited debt. Just a blank repo and a brief that said \"build it right this time.\"\n\nThe first real decision hit before I wrote a single component. Not React vs. something else â that was settled. The question was: *what handles routing? What handles server state? What handles forms? What handles client state?* Each one is a separate choice. Each one affects the others.\n\nI've been in that moment before. Different company, different domain, same paralysis. And I've watched teams make the same mistake each time: they pick tools independently, without thinking about how they work together. React Router because someone knows it. Redux Toolkit because it was used at the last job. React Hook Form because of a tutorial someone bookmarked. Three months in, you have routing that's loosely typed, server state duplicated in Redux, and form validation that doesn't talk to your query layer. The tools work in isolation. The system doesn't.\n\nThat insurance CRM was where I fully committed to TanStack as an ecosystem. Not because it was new. Because it was the most coherent answer I'd found to a question I'd been asking for years: *how do these pieces actually fit together?*\n\nThis is the first article in the **React Playbook** series â a hands-on guide to building production React apps with TanStack and a thoughtful architecture. We're starting here, at the beginning, because the choices you make in the first hour of a project shape everything that comes after.\n\n## What TanStack Actually Is\n\nTanStack is a family of headless, framework-agnostic libraries maintained primarily by Tanner Linsley. The word *family* matters. These packages are designed to work independently â you can use TanStack Query with React Router, or TanStack Router with SWR â but they're built by the same team with consistent APIs, consistent TypeScript patterns, and a consistent philosophy about what a library should own vs. what your application should own.\n\nThe four packages that form the core of this series:\n\n- **TanStack Query** â async state. Fetching, caching, background refetches, mutations, optimistic updates. It doesn't care how you fetch; it cares about *what happens to the data after you fetch it*.\n- **TanStack Router** â type-safe routing. Every route param, every search param, every navigate call is typed against your actual route tree. No `useParams<{ id: string }>()` manual type assertions.\n- **TanStack Form** â form state management. Headless, composable, works with any validation library. Deep integration with Query mutations comes naturally.\n- **TanStack Start** â full-stack framework built on top of TanStack Router. SSR, server functions, streaming. We'll cover this in article #15. For now, know it exists.\n\nWhat's not in TanStack: client state management â the kind of state that lives only in the browser and doesn't come from a server. For that, this series uses **Reatom**. I'll explain that choice properly in article #7. For now, note that `useState` and TanStack Query handle the vast majority of state in a typical app. Reatom enters the picture when you have genuinely complex reactive graphs.\n\n>
1 The best stack isn't the one with the individually best tools. It's the one where the tools share a language.\n\n## Two Ways to Start\n\n### Option 1: Vite (SPA)\n\nIf you're building a client-rendered SPA â with a separate API, no SSR, no server functions â Vite is the starting point:\n\n```bash\nnpm create vite@latest my-crm-app -- --template react-ts\ncd my-crm-app\nnpm install\n```\n\nThen add the TanStack core:\n\n```bash\nnpm install @tanstack/react-query @tanstack/react-router @tanstack/react-form\nnpm install -D @tanstack/router-devtools @tanstack/react-query-devtools\n```\n\nAdd Zod for schema validation â it integrates directly with TanStack Form and pairs naturally with TypeScript:\n\n```bash\nnpm install zod\n```\n\nThat's the complete starting dependency set. Not ten packages. Five.\n\n### Option 2: TanStack Start (SSR / Full-stack)\n\nIf you need SSR, server functions, or want to colocate data fetching with your routes:\n\n```bash\nnpx create-tsrouter-app@latest my-crm-app --template start-basic\ncd my-crm-app\nnpm install\n```\n\nTanStack Start comes with React, TanStack Router, and Vinxi (the underlying build layer) preconfigured. You add TanStack Query and Form separately â same commands as above.\n\nFor the rest of this article â and the majority of this series â I'll use the Vite setup. We'll revisit TanStack Start in article #15 when we get to SSR and full-stack patterns.\n\n## The Router Setup\n\nTanStack Router supports both file-based and code-based route definitions. I use code-based for new projects. It's more explicit, easier to understand when you're learning the ecosystem, and easier to refactor when your route tree changes.\n\nStart by creating the route tree:\n\n```tsx\n// src/router.ts\nimport { createRouter, createRoute, createRootRoute } from '@tanstack/react-router';\nimport { RootLayout } from './layouts/RootLayout';\nimport { DashboardPage } from './pages/DashboardPage';\nimport { PolicyListPage } from './pages/PolicyListPage';\nimport { PolicyDetailPage } from './pages/PolicyDetailPage';\n\nconst rootRoute = createRootRoute({\n component: RootLayout,\n});\n\nconst dashboardRoute = createRoute({\n getParentRoute: () => rootRoute,\n path: '/',\n component: DashboardPage,\n});\n\nconst policyListRoute = createRoute({\n getParentRoute: () => rootRoute,\n path: '/policies',\n component: PolicyListPage,\n});\n\nconst policyDetailRoute = createRoute({\n getParentRoute: () => rootRoute,\n path: '/policies/$policyId',\n component: PolicyDetailPage,\n});\n\nconst routeTree = rootRoute.addChildren([\n dashboardRoute,\n policyListRoute,\n policyDetailRoute,\n]);\n\nexport const router = createRouter({ routeTree });\n\ndeclare module '@tanstack/react-router' {\n interface Register {\n router: typeof router;\n }\n}\n```\n\nThe `Register` declaration at the bottom is what makes everything type-safe. From this point forward, every `useParams()`, every `<Link to=\"\">`, every `navigate()` call is checked against your actual route tree. If you rename `$policyId` to `$id`, TypeScript will tell you every single broken reference in the codebase. That's not a nice-to-have at scale. That's essential.\n\n## The App Entry Point\n\n```tsx\n// src/main.tsx\nimport React from 'react';\nimport ReactDOM from 'react-dom/client';\nimport { QueryClient, QueryClientProvider } from '@tanstack/react-query';\nimport { RouterProvider } from '@tanstack/react-router';\nimport { ReactQueryDevtools } from '@tanstack/react-query-devtools';\nimport { router } from './router';\n\nconst queryClient = new QueryClient({\n defaultOptions: {\n queries: {\n staleTime: 1000 * 60 * 5,\n retry: 1,\n },\n },\n});\n\nReactDOM.createRoot(document.getElementById('root')!).render(\n <React.StrictMode>\n <QueryClientProvider client={queryClient}>\n <RouterProvider router={router} />\n <ReactQueryDevtools initialIsOpen={false} />\n </QueryClientProvider>\n </React.StrictMode>,\n);\n```\n\nTwo decisions embedded here that are worth naming explicitly.\n\n**`staleTime: 1000 * 60 * 5`** â five minutes. By default, TanStack Query considers data stale immediately and refetches on every component mount and every window focus event. For a CRM where users navigate between the same policy records repeatedly, that's a lot of unnecessary network traffic. Five minutes as a global baseline is a reasonable starting point. You'll override it per-query when freshness matters more.\n\n**`retry: 1`** â retry failed requests once, not three times (the default). The default behavior means a failed API call spends up to 30 seconds retrying before your user sees an error. In a CRM, users notice latency. Fail fast, show the error, let the user decide whether to retry.\n\n## Your First Query\n\nA policy list page. Here's what the data layer looks like end to end:\n\n```ts\n// src/api/policies.ts\nimport type { Policy } from '../types/policy';\n\nexport async function fetchPolicies(): Promise<Policy[]> {\n const res = await fetch('/api/policies');\n if (!res.ok) throw new Error(`Failed to fetch policies: ${res.status}`);\n return res.json();\n}\n```\n\n```tsx\n// src/pages/PolicyListPage.tsx\nimport { useQuery } from '@tanstack/react-query';\nimport { Link } from '@tanstack/react-router';\nimport { fetchPolicies } from '../api/policies';\n\nexport function PolicyListPage() {\n const { data: policies, isLoading, isError } = useQuery({\n queryKey: ['policies'],\n queryFn: fetchPolicies,\n });\n\n if (isLoading) return <div>Loading policies...</div>;\n if (isError) return <div>Failed to load policies</div>;\n\n return (\n <ul>
1\n {policies.map((policy) => (\n <li key={policy.id}>\n <Link to=\"/policies/$policyId\" params={{ policyId: policy.id }}>\n {policy.policyNumber}\n </Link>\n </li>\n ))}\n </ul>\n );\n}\n```\n\nA few things worth making explicit.\n\n**`queryKey: ['policies']`** â this is the identity of the data. TanStack Query caches, deduplicates, and invalidates data by key. When you update a policy, you'll call `queryClient.invalidateQueries({ queryKey: ['policies'] })` and this list will automatically refetch. The key design decision: keep keys predictable and structured. We'll go deep on key patterns in article #5.\n\n**`<Link to=\"/policies/$policyId\" params={{ policyId: policy.id }}>`** â fully type-checked. The `to` prop only accepts valid routes from your route tree. The `params` prop is typed to exactly what that route expects. You cannot pass a typo here and have it compile.\n\n## What You Don't Need Yet\n\nI keep coming back to this piece of advice because I've seen it ignored repeatedly on greenfield projects.\n\nDo not install:\n\n- An i18n library until you actually have a second language requirement\n- A component library until you understand your design system constraints\n- A global state manager beyond `useState` until Query's cache isn't enough\n- A charting library until you're building a chart\n- An analytics package until the product is real\n\nEvery package you add before you need it is a decision made without enough information. The \"complete stack\" instinct â wanting to assemble everything before writing features â consistently results in wrong choices that are expensive to undo.\n\nHonestly, the most common refactor I do on client codebases is removing things. Unused dependencies, abstractions that outlived their problems, config for features that were never built.\n\nStart with the minimum. Add what you need when you need it.\n\n## Project Structure\n\nArticle #3 covers FSD (Feature-Sliced Design) properly, so I won't go deep here. But the starting skeleton that works for the first 20-30 features of any project:\n\n```\nsrc/\n api/ â fetch functions, typed response shapes\n pages/ â page-level components wired to routes\n layouts/ â RootLayout, AuthedLayout, etc.\n components/ â shared, reusable UI\n hooks/ â shared React hooks\n types/ â TypeScript interfaces and domain types\n router.ts â route tree definition\n main.tsx â app entry point\n```\n\nThis structure is deliberately flat. It scales to about 25-30 features before it starts feeling crowded. At that point, you'll naturally feel the pull toward domain-based organization â and that's when FSD becomes the right tool. We'll get there.\n\n## Running It\n\n```bash\nnpm run dev\n```\n\nYou'll see two devtools panels appear: the TanStack Router devtools in the bottom corner and the React Query devtools floating panel. Both are worth learning. Router devtools shows your matched route, route params, and pending navigations. Query devtools shows every active query, its status, its data, its stale time, and lets you manually invalidate or refetch. Remove them for production by checking `import.meta.env.DEV`.\n\n## What Comes Next\n\nThis setup â Vite + TanStack Query + TanStack Router â is the foundation everything else in this series builds on. Over the next 29 articles:\n\n- **Article #2:** [TypeScript patterns specific to this stack](/blog/react-playbook-typescript-patterns) â generics in query hooks, typed API layers, discriminated unions for UI states\n- **Article #3:** [FSD architecture](/blog/react-playbook-code-structure) â when the flat `pages/` structure starts hurting and what replaces it\n- **Article #4:** Error handling â Error Boundaries, query error states, global error strategies\n- **Article #7:** Reatom â when your client state grows beyond what `useState` handles cleanly\n\nThe same QueryClient, the same router, the same project structure â growing incrementally instead of being replaced. That's the goal.\n\n---\n\nThat insurance CRM shipped twelve months later. Four developers, significant business logic, a forms-heavy UI with dozens of policy types. The TanStack ecosystem handled all of it without once making us fight our own tools. That's the standard I hold stacks to: not \"is it powerful,\" but \"does it get out of the way.\"\n\nIf you're starting a new React project and want to talk through stack choices before committing â feel free to reach out. I've had this conversation more times than I can count and it rarely takes as long as people expect.\n"},{id:"smeared-component",title:"The Smeared Component: When Architecture Scatters What Should Stay Together",excerpt:"FSD is one of the best frontend methodologies out there. Used without thinking, it turns a complex component into a scavenger hunt across eight folders. Here's what I learned.",tags:["Architecture","React","FSD","DX","Frontend"],date:"May 9, 2026",readTime:"8 min read",coverImage:"/blog/smeared-component/cover.png",content:"\nI've been writing code since 2003. That's over twenty years of watching patterns come and go â and occasionally making the same mistakes the patterns were supposed to prevent.\n\nMy first serious FSD project was one of those mistakes.\n\nFeature-Sliced Design is one of the most popular frontend architectures today. The docs are clear, the community is active, the rules make sense on paper. You read through it and something clicks: pages here, features there, entities in their own layer, shared utilities at the bottom. Clean. Logical. Structured.\n\nSo I followed the rules. Meticulously.\n\nAnd I ended up with a codebase where a single button â one button that validated a form field â lived across ten folders. Its logic was in `features/`. Its type was in `entities/`. Its hook was in `shared/hooks/`. Its analytics call was somewhere in `features/analytics/`. Its test was buried in `__tests__/features/`. To understand what that button did, you had to open half the project.\n\nI was frustrated. For a while, I blamed myself â maybe I didn't understand FSD well enough. Maybe I was doing it wrong. It took me some time to realize: the problem wasn't the architecture. The problem was that I had stopped thinking.\n\n## The Smeared Component\n\nThere's a pattern I've started calling the *smeared component*. You've probably seen it. You might have built it.\n\nIt happens when a single feature â a payment form, an order widget, a settings panel â gets distributed across the project's folder structure \"correctly.\" Each piece goes to its designated layer. Hooks go to `hooks/`. Types go to `types/`. API calls go to `api/`. Validators to `validators/`. Stories to `stories/`. Tests to `__tests__/`.\n\nThe result looks organized from the outside. Every folder has the right things in it.\n\nBut try to understand the component. Try to fix a bug in
1it. Try to onboard a new developer onto it. You'll open one file, realize you need another, follow an import, land in a third folder, lose track of the original file, open a fourth â and fifteen minutes later you're not sure you've seen all the pieces yet.\n\n> Code is read far more often than it is written. Architecture should optimize for understanding â not just separation.\n\nThe smeared component optimizes for separation. It's perfectly layered. And it's nearly impossible to hold in your head.\n\n## A Real Example: PaymentMethodSelector\n\nLet me show you what this looks like in practice.\n\nImagine a `PaymentMethodSelector` component â the kind you see on checkout pages. It fetches available payment methods from an API, displays them with icons and descriptions, handles selection state, validates the selected method against order constraints, shows a modal for adding a new card, fires analytics events on selection change, and stores the result in global state.\n\nA well-intentioned FSD implementation might distribute it like this:\n\n```\nsrc/\n features/\n payment-method/\n ui/\n PaymentMethodSelector.tsx â main component\n PaymentMethodOption.tsx\n AddPaymentModal.tsx\n entities/\n payment/\n model/\n payment.store.ts â Zustand store\n payment.selectors.ts\n api/\n payment.api.ts â API calls\n types/\n payment.types.ts â types and interfaces\n shared/\n hooks/\n usePaymentValidation.ts â validation logic\n analytics/\n payment.analytics.ts â analytics events\n lib/\n formatters/\n payment.formatters.ts â formatting helpers\n __tests__/\n features/\n PaymentMethodSelector.test.tsx\n stories/\n PaymentMethodSelector.stories.tsx\n```\n\nEight locations. For one component.\n\nNow imagine the validation logic needs to change â a new payment method has different constraints. You update `usePaymentValidation.ts`. The type needs updating â `payment.types.ts`. The API response shape changed â `payment.api.ts`. The analytics event needs a new field â `payment.analytics.ts`. Update the test â somewhere in `__tests__/features/`. Update the story. Six folders. One change.\n\nEvery bug fix is an archaeological dig. Every refactor is a leap of faith.\n\nHere's what the same component looks like with colocation:\n\n```\nsrc/\n features/\n payment-method/\n PaymentMethodSelector.tsx\n PaymentMethodSelector.types.ts\n PaymentMethodSelector.hooks.ts\n PaymentMethodSelector.api.ts\n PaymentMethodSelector.store.ts\n PaymentMethodSelector.analytics.ts\n PaymentMethodSelector.utils.ts\n PaymentMethodSelector.test.tsx\n PaymentMethodSelector.stories.tsx\n ui/\n PaymentMethodOption.tsx\n AddPaymentModal.tsx\n```\n\nEverything that changes together lives together. One folder. One mental unit. One place to look.\n\n## \"But FSD Doesn't Allow That\"\n\nI can already hear the objection: \"FSD has rules. Types belong in entities. Hooks belong in shared. You can't just put everything in features.\"\n\nHere's the thing: FSD is a tool. Not a commandment.\n\nThe methodology was designed for the common case â and the common case is a typical CRUD feature with thin logic and shared primitives. For a simple `UserAvatar` component, fine â put the type in entities, the API call in shared. The overhead is minimal because the component is minimal.\n\nBut for a complex, domain-rich feature like `PaymentMethodSelector`? The cost changes. Every \"correct\" placement is a navigation tax you pay every time you touch the component.\n\nFSD actually allows adaptation. You can add a `core/` layer for truly foundational cross-cutting concerns. You can extend `shared/` with domain-specific subfolders. You can colocate everything within a feature when that feature is complex enough to justify it. The methodology explicitly says the structure should serve the project â not the other way around.\n\nI've worked on a project where the team extended FSD's layer model to add a `core/` layer for infrastru
1cture-level services: logging, auth tokens, global config. Things that don't belong to any feature but are too specific for `shared/`. It worked cleanly. No FSD rule was violated â because the rule was never \"these are the only layers you can have.\" The rule was \"maintain the import direction and keep dependencies explicit.\"\n\n> Architecture is not what you do by the rules. It's how you solve the problem in front of you â in a way that stays solvable tomorrow.\n\n## Why We Keep Doing This\n\nThe reason we smear components is usually good intentions. We read the docs. We follow the patterns. We want the project to be \"clean.\" We've been burned by messy codebases before and we never want to go back.\n\nSo we apply the architecture top to bottom, folder by folder, layer by layer. And it works. Until the project grows. Until the component gets complex. Until someone new joins and spends three days just finding where things are.\n\nI've worked in legacy codebases where the original authors followed every rule. The folder structure is immaculate. And the cognitive load of working in it is brutal â because everything *looks* right but nothing *reads* right. Opening a component means opening a new tab for the hook, a new tab for the type, a new tab for the API call. Your brain is juggling six contexts at once.\n\nI've been that original author too, on my first FSD project. I was so focused on placing files correctly that I stopped asking whether the placement made the code better.\n\n## What Architecture Actually Optimizes For\n\nGood architecture optimizes for:\n\n- **Locality** â related things are close to each other\n- **Discoverability** â you can find what you're looking for without a map\n- **Changeability** â when requirements change, the blast radius is predictable\n- **Cognitive simplicity** â a developer can hold the relevant context in their head\n\nLess useful to optimize for:\n\n- Folder aesthetics\n- Layer purity\n- DRY at the cost of everything else\n\nThese aren't opposites. A well-applied FSD project can nail all the good ones. But \"well-applied\" requires judgment, not just rule-following.\n\nThe principle I keep coming back to: **things that change together should live together.** It sounds simple. It's not always easy to apply. But it's the right question to ask before you decide where a file goes.\n\n## A Practical Test\n\nBefore placing a file in its \"architecturally correct\" location, ask yourself:\n\n1. If I need to change this file, what other files will I need to open at the same time?\n2. Are those files nearby â or spread across the project?\n3. If a new developer needed to understand this feature, how many folders would they need to open?\n\nIf the answers make you uncomfortable, you're probably optimizing for layer purity instead of developer experience. Reconsider the placement.\n\nThis isn't \"ignore architecture.\" It's the opposite â it's using the architecture deliberately. FSD, done thoughtfully, is excellent. Applied mechanically â rule by rule, folder by folder, without judgment â it produces smeared components and frustrated developers.\n\nI've worked on both kinds of projects. The difference wasn't the architecture they used. It was the thinking behind it.\n"},{id:"ai-agent-memory-redesign",title:"The AI Agent Memory System I Built Was Broken â Here Is the Redesign",excerpt:"Append-only markdown memory works great â until it doesn't. After watching the system degrade in production, our team redesigned it from scratch with strict principles: small, high-signal, controlled, and self-maintaining.",tags:["AI","Architecture","DX","Workflow"],date:"Apr 11, 2026",readTime:"10 min read",coverImage:"/blog/ai-agent-memory-redesign/cover.png",content:'\nA few weeks ago I wrote about [giving your AI agent a persistent memory](/blog/give-your-ai-agent-a-memory) â a simple system of markdown files the agent reads and writes across sessions. I was excited about it. It worked. I rolled it out on my personal project, recommended it to colleagues, and eventually submitted a pull request adding the memory system to our main company repository.\n\nThat PR triggered something I did not expect.\n\nA manager reached out and suggested organizing a meeting â not to approve or reject the PR, but because several people at the company had already been experimenting with similar ideas independently. Before the meeting even happened, there was a chat where people started sharing their experiences. Engineers who had been quietly running local memory systems of their own, not as a global company-wide thing, just something they had hacked together for themselves. They shared what worked and, more importantly, what did not.\n\nWhat I heard in that chat changed how I think about this entirely.\n\n## What Broke\n\nThe first version of the system had one rule: append. Learned something? Append it. Fixed a bug? Append it. Session ended? Append a summary. Append, append, append.\n\nThis works fine for the first few days. The
1n the files get long. Then they get contradictory. Then they become a liability.\n\nOne story from the chat stuck with me. A colleague had been working in a long session â the kind where you get deep into something and lose track of time. The agent was faithfully logging each action to a sessions file, just as instructed. Then midway through, he asked it to do something simple. Small task. Nothing complicated.\n\nThe agent picked up the session logs, scanned the history, and concluded that several things from earlier in the session had not been completed yet. So it started doing them. All of them. On top of the actual task. Everything tangled together. The agent was trying to be helpful by using the context it had been given â and it made a mess precisely because of that context.\n\nHe rolled everything back and started a fresh session. Simple task, no history, done in two minutes.\n\nThat story captures the failure mode better than any metric I could give you. The agent did exactly what you told it to do. It dutifully loaded the history, applied it to the task, and produced chaos. The problem was not the agent. The problem was that we had built a system that grew without any mechanism for forgetting.\n\nHere is what we also observed across different setups:\n\n**Context explosion.** `lessons.md` hit 200 lines in three weeks. The agent was loading all of it into context on every session. Most of it was stale or irrelevant to the current task. We were paying context budget for noise.\n\n**Drift and contradictions.** Decisions change. The agent would append a new decision without removing the old one. Now both exist. Which is current? Depends on where in the file the agent looks.\n\n**Entropy wins.** The longer a system runs without maintenance discipline, the worse it gets. Append-only systems drift toward entropy by design.\n\nWe realized the core mistake: **we treated memory like a log, not like a knowledge base.**\n\n## The Redesign Principles\n\nThe meeting itself was candid in a way that work meetings rarely are. People with months of hands-on experience with these systems, and everyone had the same conclusion: the idea is right, but the implementation costs too much to maintain. The systems degraded. They required manual cleanup. They occasionally made things worse. The good news was that everyone had independently arrived at similar fixes â which meant the fixes were probably right.\n\nThe rewrite started with constraints rather than architecture. We asked: what properties does memory need to have to not degrade?\n\n**Memory is not a log.** Do not store what happened. Store what matters. A session where nothing changed â nothing gets written.\n\n**If memory grows continuously, it is a bug.** Not a feature, not normal accumulation. A symptom of missing update rules.\n\n**Overwrite, do not append.** When knowledge changes, replace the old entry. The file should represent current state, not a history of states.\n\n**Load index first, fetch content lazily.** The agent should never blindly load all memory into context. It should load a small index, decide what is relevant, then fetch only that.\n\n**One source of truth per topic.** No two entries should describe the same thing differently. If they do, one is wrong and should be removed.\n\n## The New File Structure\n\n```\n.ai-memory/\n index.md â What exists and when to load it\n state.md â Current project state (short, always overwritten)\n decisions.md â Architecture and product decisions (overwritten, not appended)\n facts.md â Stable technical facts (rarely changes)\n tasks.md â Active tasks only (no completed tasks)\n```\n\nFive files. No sessions folder. No history.\n\nNotice what is gone: the sessions directory. We removed it. It was generating the most noise for the least value. If you need an audit trail, use git â that is what git is for. Memory is not git.\n\n### index.md\n\nThis is the only file the agent loads unconditionally at the start of a session. Everything else is fetched on demand.\n\n```markdown\n# Memory Index\n\n## Always load\n- state.md â current project state, active focus\n\n## Load when relevant\n- decisions.md â when making architecture or tech stack decisions\n- facts.md â when working with the API, data schema, or deployment\n- tasks.md â when planning work or checking what is in progress\n\n## Topics â files\n- auth flow â facts.md#authentication\n- API endpoints â facts.md#api\n- styling rules â
1 decisions.md#styling\n- current sprint â tasks.md\n```\n\nThe index is a routing table. The agent reads it, decides what it needs, loads only that.\n\n### state.md\n\nOne file, always overwritten. Never appended.\n\n```markdown\n# Current State\n\nUpdated: 2026-04-11\n\n## Active focus\nRedesigning the memory system after observing degradation in production.\nBuilding the hardened v2. Targeting minimal file count and no append logic.\n\n## Recent context\n- Completed: initial audit of degraded memory files\n- Completed: team discussion and principles alignment\n- In progress: implementing new structure across active projects\n\n## Known blockers\nNone currently.\n```\n\nThis is a snapshot, not a timeline. Next session, the agent rewrites it. Old state disappears. That is by design.\n\n### decisions.md\n\nDecisions are updated in place. When a decision changes, the entry is rewritten â not a new entry added below.\n\n```markdown\n# Decisions\n\n## Styling approach\n**Decision:** Tailwind CSS utility classes only.\n**Reasoning:** Utility-first is fast for a small team, eliminates CSS naming arguments.\n**Updated:** 2026-03-10\n\n## Blog post storage\n**Decision:** TypeScript files with markdown in template literals.\n**Reasoning:** Type-safe content, no CMS needed, works with SSG.\n**Updated:** 2026-03-15\n```\n\nNote the `Updated` field on every entry. This is the anti-drift mechanism. When you see a date from six months ago, you know to verify it is still current. When you update a decision, you update the date. Stale dates are a signal to review.\n\n### facts.md\n\nStable technical facts that rarely change. API shapes, schema, deployment configuration, environment behavior. Written once, updated when things change.\n\n```markdown\n# Facts\n\n## Authentication\n- JWT tokens, 15-minute expiry\n- Refresh tokens stored in httpOnly cookies\n- Endpoint: POST /api/auth/refresh\n\n## Deployment\n- Static export via GitHub Actions\n- Deployed to GitHub Pages\n- Custom domain: ma-x.im\n- Build command: next build (output: export)\n\n## Data schema\n- Blog posts: TypeScript files in src/data/posts/\n- Each post exports { post: BlogPost }\n- Registered in src/data/posts/index.ts\n```\n\n### tasks.md\n\nActive tasks only. Completed tasks are deleted, not archived.\n\n```markdown\n# Active Tasks\n\n## In progress\n- Implement hardened memory structure across ma-x.im project\n\n## Blocked\n(none)\n\n## Next\n- Write documentation for the new structure\n- Review and prune decisions.md across older projects\n\n---\nNo completed tasks in this file. Completed = deleted.\n```\n\nThis is a working board, not a history. Once a task is done, it disappears.\n\n## Update Rules: When to Write and When to Stay Silent\n\nThis was the most important thing we formalized. The first system had vague rules: "record things when appropriate." Agents interpreted this as "record everything always." The new rules are explicit about silence:\n\n**Write when:**\n- A decision was made that did not exist before\n- An existing decision was changed\n- A new stable fact was discovered\n- The current focus shifted significantly\n- A bug was encountered that reveals a non-obvious constraint\n\n**Do NOT write when:**\n- You completed a routine task (the task file handles this, no memoir needed)\n- You repeated something already in memory\n- The session produced no new knowledge\n- You would only be adding a timestamp without changing content\n\n**Overwrite rules:**\n- `state.md` â always overwrite entirely\n- `tasks.md` â remove completed tasks, add new ones\n- `decisions.md` â edit the existing entry, update the date\n- `facts.md` â edit in place, same as decisions\n\nIf you cannot identify which existing entry to update â stop and check if the new information belongs in memory at all.\n\n## Consolidation: What We Called "Dreaming"\n\nOne thing we kept from the meeting discussion was the idea of an explicit consolidation step. Call it memory maintenance, pruning, or â more evocatively â dreaming. The agent is not doing real work during this step. It is reviewing its own memory for quality.\n\nThe consolidation prompt looks like this:\n\n```markdown\nReview all memory files. For each file:\n1. Remove entries that are no longer current or relevant\n2. Merge entries that describe the same thing\n3. Resolve contradictions â keep the most recent or most accurate entry\n4. Compress verbose entries into concise ones\n5. Check that facts.md and decisions.md have Updated dates\n6. Verify tasks.md contains only active tasks\n\nDo not add new information during consolidation.\nOutput a summary of what was changed and why.\n```\n\nRun this manually every two to four weeks, or when memory files feel like they are growing again. It takes a few minutes and resets the quality level. Think of it as code review, but for your agent\'s knowledge.\n\n## Loading Strategy: Index First, Lazy Fetch\n\nThe old system: agent reads everything, every session. Expensive and noisy.\n\nThe new system: agent reads `index.md` first, then decides what to load.\n\nPseudo-logic the agent follows:\n\n```\nsession_start:\n load index.md\n load state.md (always â it is small and always relevant)\n\n if task involves technology decisions:\n load decisions.md\n \n if task involves API, schema, or deployment:\n load facts.md\n \n if task involves planning or sprint work:\n load tasks.md\n\nsession_end:\n if state has changed â overwrite state.md\n if a decision was made or changed â update decisions.md\n if new stable fact was discovered â update facts.md\n if tasks changed â update tasks.md\n if nothing materially changed â write nothing\n```\n\nThe index also contains a topic-to-file routing table. If the task is about authentication, the agent looks up "auth" in the index, sees it maps to `facts.md#authentication`, and loads only that section if the file supports section loading.\n\n## Good Memory vs Bad Memory\n\nHere is what the degraded system looked like, and what the hardened version looks like for the same information.\n\n**Bad â lessons.md after three weeks:**\n\n```markdown\n- Fixed a bug with template literals and backticks - need to escape them\n- Tailwind is our CSS approach\n- Remember to update the blog index when adding posts\n- Spent 2 hours debugging the sitemap â turned out to be a missing trailing slash\n- The agent keeps using CSS modules even though we use Tailwind â told it again\n- Fixed template literal backtick issue again in a different file\n- Reminder: blog posts need to be registered in index.ts\n- Auth tokens expire after 15 min â need refresh logic\n- AGAIN: escape backticks in template literals\n```\n\nThree separate entr
1ies about the same backtick bug. Two reminders about the blog index. This is what append-only looks like in practice.\n\n**Good â facts.md with a single consolidated entry:**\n\n```markdown\n## Blog post authoring\n- Posts are TypeScript files in src/data/posts/, registered in index.ts\n- Content is a template literal â backticks inside content must be escaped as \\`\n- Dates use format: "Apr 11, 2026"\n```\n\nOne entry. Everything relevant. Zero repetition.\n\n## Honest Trade-offs\n\nThis system is better, but it is not free:\n\n**You lose history.** There is no record of what was decided and then changed. If you care about that trail, decisions.md has `Updated` dates, but it does not track the previous value. For audit purposes, commit your memory files to version control â git gives you the history.\n\n**Consolidation requires effort.** The dreaming step will not happen automatically unless you explicitly schedule it. Without it, even this system will degrade eventually â just much more slowly.\n\n**The agent still needs good instructions.** Vague instructions produce vague memory behavior. The more specific your rules (especially the "do NOT write" cases), the more disciplined the output.\n\n**Index routing is manual.** The topic-to-file mappings in `index.md` need to be maintained by hand. A stale index routes the agent to the wrong file. Keep it accurate.\n\n## Summary\n\nI submitted that PR thinking I was sharing something solid. The pre-meeting chat humbled me a little â people had hit the same walls I had not hit yet, they just had not written about it publicly.\n\nThe honest conclusion from all of that experience: the naive version works until it does not, and when it stops working it does so quietly. You do not get an error. You get an agent that does almost the right thing, confidently, based on stale context you forgot was there. That is harder to debug than a crash.\n\nThe hardened version applies one core idea: **memory should represent current knowledge, not accumulated history.** Everything else follows from that â overwrite instead of append, index-based loading instead of bulk dumps, explicit silence rules, no sessions folder.\n\nThe agent does not need a journal. It needs a working knowledge base that stays reliable over time.\n\nBuild that instead.\n'},{id:"give-your-ai-agent-a-memory",title:"Give Your AI Agent a Memory â It Will Stop Disappointing You",excerpt:"A simple file-based memory system that turns AI agents from forgetful interns into reliable collaborators. Works for engineers, designers, writers, and anyone who uses AI tools daily.",tags:["AI","Productivity","DX","Workflow"],date:"Mar 26, 2026",readTime:"6 min read",coverImage:"/blog/give-your-ai-agent-a-memory/cover.png",content:'\nI was mass-scrolling LinkedIn and almost missed it. A [post from Dmytro Sergiev](https://www.linkedin.com/posts/dmytro-serg-909385b5_%D1%89%D0%BE%D1%80%D0%B0%D0%B7%D1%83-%D0%BF%D0%BE%D1%8F%D1%81%D0%BD%D1%8E%D1%94%D1%88-ai-%D0%B0%D0%B3%D0%B5%D0%BD%D1%82%D1%83-%D0%BE%D0%B4%D0%BD%D0%B5-%D0%B9-%D1%82%D0%B5-%D1%81%D0%B0%D0%BC%D0%B5-activity-7440664293827567616-i37k/?utm_source=ma-x.im&utm_medium=member_desktop&rcm=ACoAAARw5RMBaP7bUQuqKf2SLQvB7dtX0aJT9Fo) â a Product Manager, not an engineer â about how he gives his AI agent a persistent memory using plain folders and markdown files. And something clicked.\n\nI had been annoyed for months. Every new chat session with an AI agent â same questions, same wrong assumptions, same "what framework are you using?" dance. I would explain my project setup, my conventions, my stack. The agent would do decent work. Next day â blank slate. All of it, gone. I knew the problem was not the model itself â I understood that AI needs context to be useful. But I could not figure out a sustainable way to give it that context. The problem was amnesia.\n\nDmytro\'s idea was dead simple: give the agent a folder it can read and write between sessions. I grabbed this concept, adapted it for my frontend engineering workflow, and started rolling it out across every project I work on. It has been a few weeks now, and honestly â I cannot believe I worked without this for so long.\n\n> **Update â April 2026:** This post describes v1 of the system. After about three weeks of real use â across multiple projects and a team discussion â I ran into the limits of the append-only approach and redesigned it from scratch. The core idea here still holds and is worth reading for context. The hardened version is in [The AI Agent Memory System I Built Was Broken â Here Is the Redesign](/blog/ai-agent-memory-redesign).\n\n## The Problem Is N
1ot the Model â It Is the Amnesia\n\nThink about what happens when you open a fresh chat and say "add a blog post to my site." The agent has no idea what framework you use. It does not know how your content is structured. It does not know you spent two hours last Tuesday figuring out that your blog posts are TypeScript files with markdown in a template literal. It does not know that you tried CSS-in-JS once and it was a disaster.\n\nSo it guesses. You correct. It guesses again. You correct again. Twenty minutes pass before any real work happens. And tomorrow? Same thing. From zero.\n\nI always understood that the model needs context. That was never the question. The question was â how do you actually give it enough? Context is not a document you write once. It is years of decisions, conventions, mistakes, preferences â all living in your head. You cannot dump all of that into a prompt. And even if you could, it would be out of date by next week. I needed a system, not a one-time fix.\n\n## The Fix: A Folder the Agent Can Read and Write\n\nCreate a `.ai-memory/` folder in your project root. Put a few markdown files in it. Tell the agent to read them at the start of every session and update them as it learns. Seriously, that is the whole idea.\n\n```markdown\n.ai-memory/\n context.md â What this project is, who works on it, goals, constraints\n decisions.md â Key decisions made, with reasoning\n patterns.md â Conventions, naming rules, recurring patterns\n lessons.md â Mistakes, gotchas, things to avoid\n sessions/\n 2026-03-25.md â What was done today, what was learned\n 2026-03-24.md â Yesterday\'s session\n```\n\nFive files and a folder. No database. No API. No npm packages. Just markdown files that a human can read too.\n\n## What Goes in Each File\n\n**context.md** is the project\'s identity card. Who you are, what the project does, the tech stack, the constraints. For a React project it might say "Next.js 15, App Router, Tailwind, deployed to Vercel." For a marketing project it might say "Brand guidelines in /docs, tone is professional but warm, target audience is B2B SaaS." For an accounting workflow it could describe the tools, the report formats, the compliance rules.\n\n**decisions.md** captures the "why" behind choices. Not just "we use Tailwind" but "we use Tailwind because utility-first is fast for a small team and avoids CSS naming debates." When the agent encounters a similar decision later, it does not propose something contradictory.\n\n**patterns.md** records how things are done in this specific project. File naming conventions. Component structure. How blog posts are stored. Where images go. The things a new team member would ask on their first day â except the agent finds them on its own.\n\n**lessons.md** is the file that surprised me the most. I did not expect it to matter this much. Every bug, every gotcha, every "do not do this because..." entry prevents the agent from repeating the same mistake. I had a bug in one of my projects where backticks inside a template literal were breaking the build. I fixed it, moved on, and the next day the agent generated the exact same bug. After I added "backticks inside template literal content must be escaped" to lessons.md â never happened again. Three words in a markdown file saved me from debugging the same issue three times.\n\n**sessions/** logs keep a running history. After each session the agent writes what it did, what decisions were made, what was learned. These logs feed future sessions with recent context.\n\n## Why This Works for Everyone â Not Just Engineers\n\nHere is what struck me about Dmytro\'s original post: he is not a developer. He is a Product Manager who works with data, strategy, and AI-assisted research. He does not write React components. He uses VS Code as a workspace for documents and workflows. And the memory system works just as well for him â maybe better, because his context is even harder for an agent to guess.\n\nThink about an analyst who works with multiple data sources, specific report formats, metric definitions that differ between clients, and compliance rules that change quarterly. Every time they start a new AI session, they would have to re-explain all of that. With a memory folder, the agent already knows: "Client X uses quarterly cohort analysis, reports go in /reports/YYYY-QN/, retention is measured as 30-day active, not 30-day login." That is not code. That is just domain knowledge written in plain text. And it makes the agent dramatically more useful.\n\nThe same applies to product managers tracking sprint context, designers recording spacing conventions and color tokens, writers maintaining tone guidelines. The files are plain markdown. No code required. If you can write a bullet point, you can give your agent a memory.\n\n## The Practical Difference\n\nLet me give you a real example. Last week I came back to a project I had not touched in six days. Old me would have spent the first 15 minutes re-explaining the setup: "This is a Next.js site, blog posts are TypeScript files, we use Tailwind, content goes in src/data/posts..." Instead, I just said "create a new blog post about agent memory." The agent read context.md and patterns.md, understood the file structure, the naming conventions, the BlogPost type â and produced a working post file on the first try. Fifteen minutes of setup reduced to zero.\n\nOr this one: I kept correcting the agent â "we use Tailwind, not CSS modules." Every. Single. Session. After I wrote it in decisions.md, that correction disappeared permanently. A one-line entry fixed a daily annoyance.\n\nThe thing that gets me is how it compounds. After a week, the agent knows your project better than a
1new team member after onboarding. After a month, going back to a project without memory feels broken â like opening a codebase with no README, no comments, nothing. You realize how much invisible context you were carrying in your head and re-typing every day.\n\n## How to Set This Up in Any Project\n\nHere is the step-by-step. Takes about five minutes.\n\n**Step 1:** Create the folder structure in your project root:\n\n```bash\nmkdir .ai-memory\nmkdir .ai-memory/sessions\n```\n\n**Step 2:** Create the instruction file. If you use VS Code with GitHub Copilot, put this in `.github/copilot-instructions.md`. If you use Claude/Cursor, put it in `.cursorrules` or the equivalent config file.\n\nHere is the full prompt you can copy:\n\n```markdown\n# Agent Memory System\n\nYou have access to a persistent memory system stored in .ai-memory/\nat the root of this project. Use it to maintain context across sessions,\navoid asking repeated questions, and make better decisions over time.\n\n## Memory Structure\n\n.ai-memory/\n context.md â Project identity, tech stack, goals, constraints\n decisions.md â Key technical decisions with reasoning\n patterns.md â Conventions, naming patterns, project-specific rules\n lessons.md â Mistakes, gotchas, things to remember and avoid\n sessions/\n YYYY-MM-DD.md â Session log: what was done, decided, learned\n\n## Rules\n\n### At the Start of Every Session\n1. Read context.md, decisions.md, patterns.md, and lessons.md.\n2. Use this information to inform your work. Do not ask questions\n already answered in these files.\n3. If a file does not exist yet, create it when you have information\n worth recording.\n\n### During Work\n- Non-trivial decisions -> record in decisions.md\n- Discovered conventions -> record in patterns.md\n- Bugs, gotchas, mistakes -> record in lessons.md\n- Project goals or constraints -> update context.md\n\n### Mandatory Memory Checkpoints\nYou MUST update memory at these moments. Do not skip. Do not defer.\n1. After fixing a bug or discovering a gotcha -> add to lessons.md immediately.\n2. After completing a multi-step task -> update relevant memory files right away.\n3. When the user shares a preference or constraint -> update context.md or patterns.md within the same response.\n4. Before responding with "done" -> check if anything should be in memory first.\n\n### After Significant Work\n- Create or append to sessions/YYYY-MM-DD.md with:\n what was done, key decisions, lessons learned.\n\n### Writing Style\n- Concise: 1-3 sentences per entry\n- Factual: what was decided, not how you feel\n- Append-friendly: new entries at the bottom\n- No duplication: update existing entries, do not contradict\n\n### If .ai-memory/ Does Not Exist\nCreate the folder and initial files by scanning the project:\npackage.json, README, configs -> context.md and patterns.md.\nLeave decisions.md and lessons.md mostly empty â they grow over time.\n```\n\n**Step 3:** Start a new session. The agent will read the instruction, create the initial memory files by scanning your project, and begin maintaining them automatically.\n\n**Step 4:** Decide where memory lives.\n\nIf this is your personal project â commit `.ai-memory/` to the repo. You are the only one working here, so your memory is the project\'s memory. That is how I do it on ma-x.im.\n\nIf you work in a team â even three developers â add `.ai-memory/` to `.gitignore`. Memory is personal. Your thinking, your mistakes, your patterns â they are different from your colleague\'s. General project knowledge (architecture, API conventions, component rules) belongs in shared context files like `.github/copilot-instructions.md`. But memory is your private notebook. The instruction prompt stays in the repo so every developer\'s agent knows to create and maintain its own local memory â but the memory content itself stays local.\n\nThere is a possible third path: shared team memory where everyone writes to the same folder. I can see how it could work in theory, but I have not seen a strong use case for it yet. Shared context plus individual memory covers most scenarios.\n\n## Honest Limitations\n\nThis is not magic. A few things to keep in mind:\n\n- **The agent forgets to update memory.** This was my biggest surprise. I wrote clear instructions: "after fixing a bug, record
1it in lessons.md." The agent would fix the bug, confirm it was done, and move on without writing anything down. Hours of work, zero memory updates. The fix was adding explicit mandatory checkpoints to the instructions â not "record things when appropriate" but "you MUST update memory BEFORE telling me you are done." Passive instructions get ignored during complex multi-step work. Forceful, specific checkpoints work.\n- **Files can get bloated.** If lessons.md grows past 100 lines, split it or prune old entries. The agent reads these files every session â keep them focused.\n- **It depends on the agent following instructions.** Some AI tools respect system prompts better than others. GitHub Copilot in VS Code and Claude in Cursor both handle this well. Other tools may vary.\n- **It is not a replacement for proper documentation.** This is a working notebook, not a wiki. Keep real docs in real docs.\n\n## Context vs Memory â Why You Need Both\n\nThis is the distinction I wish someone had explained to me earlier.\n\n**Context** is what describes the project: architecture, API structure, component conventions, CSS rules, TypeScript config, deployment pipeline. It is documentation. You write it once, maintain it as the project evolves, and every developer on the team reads the same version. Context lives in shared files â README, copilot-instructions.md, architecture docs.\n\n**Memory** is what describes the developer: your decisions, your mistakes, your patterns, your working style. It grows organically, session by session. What you did today, what went wrong yesterday, what you learned this week â this is what makes the agent smarter over time. Context is static knowledge. Memory is accumulated experience.\n\nIn an ideal project, you need both. Context so the agent understands the codebase. Memory so it understands you. Without context, the agent guesses about your stack. Without memory, it repeats the same mistakes and asks the same questions every day.\n\nThe real shift for me was understanding that context alone is never enough. There is always more knowledge in your head than you can write down â it forms over months and years of working on a project. You cannot capture all of it upfront. The memory approach solves this differently: instead of trying to dump everything at once, it lets knowledge accumulate naturally. Every session teaches the agent something new. After a week it knows your project better than a new hire after onboarding. After a month, working without it feels broken.\n\n## Summary\n\nI always knew the model needed context. That was never the mystery. What I could not figure out was how to give it enough context without spending half my day writing documentation that would be outdated by Friday. The memory approach solved this â not by asking me to write everything upfront, but by letting knowledge accumulate naturally, session by session.\n\nFive markdown files and a prompt. Five minutes of setup. The return is immediate and it compounds â every session makes the next one smarter.\n\nThe people chasing the newest model are solving the wrong problem. The leverage is not in the model. It is in what the model knows about you and your project.\n\nIf you have feedback, alternative approaches, or just want to discuss this topic â feel free to reach out.\n'},{id:"interviewing-senior-engineers-short",title:"Stop Quizzing Senior Engineers â Start Talking to Them (TL;DR)",excerpt:"The condensed version: why conversational interviews beat trivia quizzes for senior roles, with practical examples and actionable advice.",tags:["Interviews","Engineering Culture","Leadership","Career","TL;DR"],date:"Mar 21, 2026",readTime:"6 min read",content:'\nI recently wrote a [detailed article](/blog/interviewing-senior-engineers) on this topic. It came out long because I wanted to cover every nuance â the full story, detailed interview examples, the AI angle, common mistakes, and practical formats. But I realize not everyone has 15 minutes to spare, so here is the short version covering only the essentials.\n\n## The Problem\n\nExperienced engineers fail interviews. Not because they lack skill â because the interview tests the wrong thing.\n\nI have over a decade of production frontend experience. TypeScript, React, enterprise products, design systems, team leadership. And I have failed interviews because I could not recall the exact behavior of `Promise.allSettled` under pressure or write a tree traversal algorithm from memory. These are things I almost never do by hand in real work.\n\nThe trivia-based interview format creates false negatives (rejecting experienced builders who do not drill interview prep) and false positives (passing candidates who memorized answers but cannot make architecture decisions). It tests recall under stress â a scenario that never occurs in actual engineering work.\n\n## What Works Instead\n\nTalk to the candidate like a colleague. Ask about real scenarios and follow up on every answer.\n\nThe best interview I ever had was with a guy named John at a large Canadian company in the automotive industry. Two hours. No algorithms, no trivia. Just questions like:\n\n- "You inherit a React app with 200+ components and no design system. What is your first week like?"\n- "Two teams depen
1d on the same component library but ship on different schedules. How do you handle versioning?"\n- "A junior on your team writes overly abstract code. How do you handle that conversation?"\n\nEvery answer led to a follow-up: "What if the team pushes back?" "What if the deadline is two weeks?" He was mapping how I think, not checking what I have memorized.\n\nI got the job and stayed for three years. No whiteboard session would have predicted that outcome better.\n\n## How Simple Questions Reveal Deep Expertise\n\nYou do not need complex scenarios. Start simple and follow the thread.\n\n**Example: "Your team needs to support dark mode across the product. How do you approach it?"**\n\nA junior talks about CSS variables. A senior asks: "How big is the product? Is there a design system? Does design have dark mode tokens ready? Is this a gradual rollout?" Then follow up: "Design only covers 40% of components â the rest is on you." Then: "A senior dev wants to refactor all CSS first. What do you do?" Then: "Product wants to A/B test with 10% of users."\n\nOne question. Fifteen minutes. You see problem decomposition, ambiguity handling, leadership instincts, and technical depth. No algorithm required.\n\n**Example: "Users say the app feels slow. No specific page, just... slow. What do you do?"**\n\nA senior starts with: "Before I touch code â what does slow mean to users? Initial load? Navigation? Interactions? Do we have metrics? Is it all users or specific segments?" Then you push: "Mainly mobile users on slow networks." Then: "VP wants a fix in two weeks for a board meeting." Then: "Backend says APIs are fine, it is purely frontend."\n\nTwo questions. Thirty minutes total. You know how this person operates in the real world.\n\n## Ask About AI\n\nIn 2026, skipping AI in a senior interview means missing critical signal. Some seniors write ten lines by hand per week and orchestrate AI agents for the rest. Others barely use AI. Both can be effective.\n\nThe issue: candidates will not volunteer their AI usage honestly unless you make it safe. There is still stigma.\n\nAsk openly:\n\n- "Walk me through how you built the last feature you shipped â tools, process, everything."\n- "When you use AI-generated code, what is your review process?"\n- "A developer submits a PR that is entirely AI-generated. They understand it, but did not write it by hand. How do you feel as a reviewer?"\n\nYou are evaluating judgment about tools and quality â not whether they use AI.\n\n## Where This Breaks\n\n**Requires skilled interviewers.** You cannot run this from an answer sheet. The interviewer needs domain depth and the ability to improvise follow-ups.\n\n**Consistency is hard.** Different interviewers, different questions, different evaluations. Shared rubrics and calibration sessions are essential.\n\n**Charisma bias.** Open conversations favor articulate, sociable candidates. Evaluate substance of answers, not delivery style.\n\n**Takes more time.** You need 60â90 minutes. This does not work for high-volume initial screening â use it in final rounds.\n\n## Common Mistakes\n\n- Having a "correct" answer in your head. You are evaluating reasoning, not checking solutions.\n- Being too casual. "Just a chat" produces no signal. You need evaluation criteria.\n- Not following up. The first answer is surface level. Push with "why?", "what if?", "what could go wrong?"\n- Not calibrating across interviewers. Without shared rubrics, you get incomparable evaluations.\n- Treating AI usage as a red flag. The right follow-up to "I use AI for 80% of code" is "how do you ensure quality?" â not a concerned look.\n\n## Quick Recommendations\n\n**Interviewers:** Build 5â8 scenario questions from real team challenges. Write a rubric with clear dimensions (problem decomposition, trade-off awareness, communication, technical depth, collaboration, leadership). Run calibration sessions quarterly. Ask about AI openly.\n\n**Candidates:** When you do not remember an API detail â say so and explain the concept. Prepare stories about real projects, not rehearsed definitions. Be honest about your AI workflow. Ask your own questions aggressively â they signal seniority more than your answers do. If the interview feels like an exam, that is a signal about the culture.\n\n## Summary\n\nSenior interviews should test judgment, not recall. Simple scenario questions with deep follow-ups reveal more about an engineer\'s real capability than any algorithm or trivia quiz. It takes better interviewers and more time, but you hire people who can actually do the job. In an era where AI handles routine code generation, the quality of decisions is what makes a senior engineer valuable. Test for that.\n\nFor the full deep-dive with complete interview formats and extended examples, read the [long version](/blog/interviewing-senior-engineers).\n\n---\n\n*If you have thoughts on this or want to share your own interview experience â [reach out](/contact).*\n'},{id:"interviewing-senior-engineers",title:"Stop Quizzing Senior Engineers â Start Talking to Them",excerpt:"Why conversational interviews produce better hiring signal than algorithmic puzzles and API trivia â and how one interview with a guy named John changed how I think about hiring.",tags:["Interviews","Engineering Culture","Leadership","Career","Long Read"],date:"Mar 20, 2026",readTime:"15 min read",content:'\nI have failed interviews. Multiple times. Not because I did not know my craft â I have been shipping production frontend for well over a decade. I failed because I could not recite the exact behavior of `Promise.allSettled` under pressure, or because I blanked on a tree traversal algorithm I had not hand-written since university. Meanwhile, the things I actually do every day â making architecture decisions, untangling legacy code, mentoring developers, designing systems that survive contact with real users â none of that was being tested.\n\nThis article is about what a good senior interview looks like. I know because I experienced one, and it changed everything.\n\n## Context: The Interview Problem Nobody Talks About\n\nThere is an uncomfortable truth in our industry. Many experienced engineers â people who have built and maintained complex production systems for years â routinely fail technical interviews.\n\nNot because they are bad engineers. Because the interview tests a different skill than the job requires.\n\nI work primarily with TypeScript and React. Promises? I use them every day â through async/await, through React Query, through framework abstractions. But when someone puts me in front of a whiteboard and says "chain three promises, handle partial failures, and explain the microtask queue," I stumble. Not because I do not understand the concept. Because I do not carry that specific API surface in my active memory. I have not needed to.\n\nTree traversals and graph algorithms? Honestly â I do not enjoy them and I rarely encounter them in my daily work. When I need something like that, I reach for a library or write a straightforward recursive approach. I do n
1ot sit and think about BFS vs DFS complexity tradeoffs while building a dashboard for an enterprise client.\n\nLogical puzzles? Same story. I can solve them given time and a calm environment. Under interview pressure, with someone watching me and a timer ticking? I freeze. And I know I am not alone.\n\nHere is what bothers me most: I have seen engineers who drill LeetCode for months pass these interviews with flying colors, then struggle to make a single meaningful architecture decision in their first sprint. The interview selected for the wrong thing. It tested preparation discipline, not engineering capability.\n\nI am not saying algorithmic knowledge is useless. I am saying that for a senior role, it should not be the primary filter. And yet â at most companies â it still is.\n\n## The Interview with John\n\nLet me tell you about the best technical interview I have ever experienced.\n\nIt was at a large Canadian company. Well-known products â automotive industry. The kind of place where the frontend work is complex, the user base is massive, and the decisions you make on Monday are still affecting the product two years later.\n\nMy interviewer was John. I remember his name because what happened in that interview stuck with me permanently. I still feel a kind of gratitude toward him years later â not because he went easy on me, but because he showed me what a respectful, effective technical interview looks like.\n\nWe were scheduled for ninety minutes. We went over two hours. I did not notice the time passing. Not once did I feel like I was being tested. It felt like I was having a deep technical conversation with a colleague I respected.\n\nJohn did not ask me what a closure is. He did not ask me to reverse a linked list. He did not ask me to explain the event loop.\n\nInstead, he asked things like:\n\n- "You join a team and inherit a React app with 200+ components and no design system. What is your first week like?"\n- "The product team wants to support three different brands from the same codebase. How would you approach the frontend?"\n- "You notice the CI pipeline takes 40 minutes and developers are complaining. Where do you start?"\n- "Two teams depend on the same component library but ship on different schedules. How would you handle versioning?"\n- "A junior on your team writes overly abstract code â creates a generic wrapper for everything. How do you handle that conversation?"\n\nEvery answer I gave led somewhere. John would nod and then say, "Okay, but what if the team pushes back on that approach?" or "What if the deadline is in two weeks?" or "What happens when the third team joins and they use a different state management solution?" He was not looking for a specific answer. He was mapping how I think.\n\nI got the job. I stayed for about three years. The team was strong, the work was meaningful, and as far as I could tell, the company did not regret the hire. That interview â with no algorithms, no trivia, no trick questions â predicted my actual job performance far better than any whiteboard session ever could.\n\nThat experience left me with a conviction: you learn more about a senior engineer in thirty minutes of genuine conversation than in two hours of trivia.\n\n## Why Trivia-Based Senior Interviews Are Fundamentally Broken\n\nLet me be specific about the failure modes.\n\n**The wrong skill is being tested.** When you ask a senior engineer to write a `debounce` function from memory, you are testing recall. But a senior\'s actual value lies in knowing *when* to debounce, *where* it matters for user experience, and *whether* the framework already provides a better abstraction. The API signature is a Google search away. The judgment is not.\n\n**False negatives destroy your pipeline.** The most experienced candidates â the ones who have been building real systems instead of doing interview prep â are the ones most likely to fail trivia questions. They have been too busy shipping code to memorize it. You are systematically filtering out the people you want most.\n\n**False positives are expensive.** A candidate who spent three months on an interview prep platform will ace your quiz. They might know every closure edge case, every prototype chain gotcha, every `this` binding trap. But put them in front of a real architecture decision â "should we build a micro-frontend or keep the monolith?" â and they might have nothing to say. That mismatch costs you months of onboarding and potentially a bad hire.\n\n**Stress
1is not signal.** There is a pervasive belief that "if they can perform under pressure, they will be good in production." This is wrong. Interview stress and production pressure are fundamentally different experiences. In production, you have context, tools, colleagues, and time to think. In an interview, you have a stranger staring at you while you try to remember if `Array.prototype.flat` takes a depth argument. These are not comparable situations. You are measuring anxiety tolerance, not engineering skill.\n\n**It does not match how we actually work.** In my daily work, I look things up constantly. I read documentation. I discuss approaches in Slack. I use AI to scaffold boilerplate. I do code reviews where I catch things I would not have remembered in an interview. The isolated, no-tools, no-references interview environment tests a scenario that literally never occurs in the actual job.\n\n## The Conversational Interview: What It Looks Like in Practice\n\nThe alternative is not "just chatting." A conversational interview has structure, evaluation criteria, and clear goals. But it evaluates what matters: how the candidate thinks about problems, makes decisions, and handles complexity.\n\nLet me walk through two complete examples. These are realistic â the kind of conversations you could have with a senior frontend candidate next week.\n\n### Example 1: The Innocent Question That Reveals Everything\n\nImagine you start the interview with this:\n\n**"Hey, so â your team has just been told that you need to support dark mode across the entire product. How do you approach this?"**\n\nThis sounds almost too simple. Dark mode. Every junior knows about `prefers-color-scheme`. But this question is a depth charge. Watch what happens when you follow the thread.\n\nA junior will talk about CSS variables and a toggle button. A mid-level will mention design tokens, maybe theming with styled-components or Tailwind.\n\nA senior? A senior will start asking questions back:\n\n- "How big is the product? How many pages, how many components?"\n- "Is there a design system, or are styles scattered?"\n- "Does the design team have dark mode designs ready, or are we deriving them?"\n- "What is the CSS architecture â utility-first, CSS modules, CSS-in-JS?"\n- "Are there third-party components that do not support theming?"\n- "Is this a gradual rollout or a big-bang release?"\n\nNow you are in a real conversation. Follow up with:\n\n- **"The design team says they will provide dark mode tokens, but only for the core components. The rest â about 60% of the UI â you have to figure out."** This tests whether they can handle ambiguity and make pragmatic decisions about imperfect situations.\n\n- **"One of the senior developers on the team insists on doing a full CSS refactor before implementing dark mode. He says the current styles are too messy. What do you do?"** This is no longer a technical question â it is a leadership and collaboration question. How do they handle disagreement? Do they see the tradeoff between ideal and practical?\n\n- **"Product says they want to A/B test dark mode with 10% of users first."** Now you are in feature flags, runtime theming, and state management territory. Do they think about persistence? Server-side rendering? Flash of unstyled content?\n\nOne question. Fifteen minutes. You have learned more about this person\'s seniority than any algorithm challenge could reveal. You have seen how they decompose problems, how they handle incomplete information, how they think about people and process alongside code.\n\n### Example 2: The Deceptively Simple Scenario\n\nHere is another one:\n\n**"A product manager comes to you and says: users are complaining that the app feels slow. No specific page, just... slow. What do you do?"**\n\nAgain â sounds easy. But a senior engineer\'s answer to this question reveals an enormous amount about their experience.\n\nThe junior answer: "I would use Lighthouse and fix the scores."\n\nThe mid-level answer: "I would profile the app, check bundle size, look at network requests."\n\nThe senior answer starts with: "Before I touch any code, I need to understand what \'slow\' means to the users."\n\n- "Is it initial load time? Navigation between pages? Interaction responsiveness? All of the above?"\n- "Do we have any metrics â Core Web Vitals, custom performance tracking, session recordings?"\n- "Is it slow for all users or specific segments â mobile, specific regions, specific browsers?"\n- "When did it start feeling slow? Was there a recent deployment that correlates?"\n\nFollow up:\n\n- **"Turns out it is mainly mobile users in regions with slower networks. Desktop users are fine."** This pushes toward real-world constraints â code splitting, server-side rendering, image optimization, CDN strategy. Do they think about the network layer, or only about JavaScript?\n\n- **"The VP of Engineering wants a fix in t
1wo weeks because there is a board meeting."** Now you are testing prioritization under pressure. Do they panic and promise everything? Do they push back with data? Do they suggest a phased approach â quick wins first, structural changes later?\n\n- **"The backend team says the APIs are fine and the problem is purely on the frontend."** How do they handle cross-team accountability? Do they accept that at face value, or do they know how to prove or disprove it with data?\n\nTwo questions. Two conversations. Thirty minutes total. You now have a detailed picture of how this person operates in the real world. You know their technical depth, their communication style, their leadership instincts, and their ability to work with ambiguity. No whiteboard required.\n\n### What Makes These Questions Work\n\nNotice what both examples have in common:\n\n- **The initial question is approachable.** It does not trigger "I need to remember the right answer" anxiety. It triggers "let me think about this problem."\n- **Depth comes from follow-ups, not from the question itself.** The interviewer\'s skill matters. You need to know where to push.\n- **There is no single correct answer.** Multiple good approaches exist. What you evaluate is reasoning, awareness of tradeoffs, and the ability to adapt when the situation changes.\n- **They test real-world skills.** Ambiguity handling, stakeholder management, prioritization, team dynamics â these are what senior engineers actually deal with every day.\n\n## The AI Question Every Senior Interview Needs in 2026\n\nLet me say something that might be controversial: if you are interviewing a senior engineer in 2026 and you do not ask about AI, you are missing a critical data point.\n\nHere is the reality. I know engineers â good ones, experienced ones â who write maybe ten lines of code by hand per week. The rest is orchestrating AI agents. They prompt, they review, they refine, they iterate. And they are productive. They ship features, maintain quality, and make good architectural decisions. They are still seniors â arguably more effective seniors than before.\n\nI also know engineers who barely use AI. They write everything by hand, they are meticulous, and they produce excellent work. Also valid.\n\nThe interesting question is not "do you use AI?" It is everything that comes after.\n\nBut here is the problem: candidates will not talk about this honestly unless you make it safe. There is still a stigma. Engineers worry that admitting heavy AI usage will make them look lazy or less skilled. So they downplay it. And you miss the real picture.\n\nHow to ask:\n\n- **"Walk me through how you built the last feature you shipped. Tools, process, everything."** This is open-ended enough that they can naturally mention AI without feeling judged.\n- **"When you use AI-generated code, what is your review process? What do you look for?"** This tells you whether they are using AI thoughtfully or blindly.\n- **"Have you seen AI-generated code cause a problem in production? What happened?"** A senior who uses AI regularly will have a story. And how they handled it tells you about their quality standards.\n- **"A developer on your team submits a PR that is entirely AI-generated. They understand it, but they did not write any of it by hand. How do you feel about that as a reviewer?"** This tests their philosophy about code ownership, maintainability, and team standards.\n\nWhat you are really evaluating is judgment. Does this person use AI as a power tool while maintaining engineering responsibility? Or are they outsourcing their thinking? There is a big difference, and a conversational interview is the only format that can surface it.\n\n## Where Conversational Interviews Break Down\n\nI am not going to pretend this approach is perfect. It has real limitations.\n\n**It requires skilled interviewers.** Running a conversational interview well is hard. You need someone who knows the domain deeply enough to improvise follow-ups, challenge weak answers, and recognize strong ones even when they differ from what they expected. A trivia quiz can be run by anyone with an answer sheet. A conversational interview needs a senior engineer who is also a good communicator. Not every team has that.\n\n**Consistency is genuinely difficult.** When different interviewers ask different questions in different ways, comparing candidates becomes harder. Two candidates might both be strong, but the scores look different because one interviewer went deep on architecture while another focused on team dynamics. You need a shared rubric with explicit evaluation dimensions to mitigate this.\n\n**It takes more time.** A meaningful conversational interview needs 60 to 90 minutes. You cannot rush it to 30. For companies doing high-volume hiring, that is a real constraint.\n\n**Charisma bias is a real risk.** Open-ended conversations naturally favor people who are articulate, confident, and personable. Brilliant engineers who are introverted, or who communicate better in writing than in speech, or who think slowly and carefully, can get undervalued. Your rubric needs to account for this â evaluate the substance of answers, not the delivery.\n\n**Some candidates genuinely prefer structured tests.** Not everyone thrives in ambiguity. Some great engineers feel more comfortable with a clear problem, a clear solution, and a clear evaluation. Dismissing their preference entirely is its own form of bias.\n\n## Common Mistakes When Running Conversational Interviews\n\n**Asking scenario questions but having a "correct" answer in your head.** If you already decided that the right approach to the dark mode question is "use CSS custom properties with a ThemeProvider," you are just running trivia with extra steps. The goal is to evaluate reasoning, not to check if they chose the same solution you would.\n\n**Being too casual.** "Let\'s just chat" produces zero usable signal. You need a plan: which scenarios, which follow-ups, what evaluation criteria. The conversation should feel informal. The evaluation should be rigorous.\n\n**Not going deep enough.** The first answer to any question is the surface layer. The signal is in the second and third follow-ups. "Why that approach?" "What could go wrong?" "What would you do differently with more time?" If you accept the first answer and move on, you are wasting the format.\n\n**Failing to calibrate.** Three interviewers running three different c
1onversational interviews without shared criteria will produce three incomparable evaluations and an argument in the debrief meeting. Calibration sessions â where interviewers evaluate the same recorded interview and compare notes â are not optional.\n\n**Testing outside the candidate\'s experience.** If someone has spent their career in React and you ask them to design a distributed backend system, you will get a weak answer that tells you nothing. Tailor your scenarios to the role and the candidate\'s actual background.\n\n**Treating AI usage as a negative signal.** This one deserves its own bullet point because it is still happening everywhere. If a candidate openly says "I use AI for 80% of my initial code generation," the correct follow-up is "how do you ensure quality and maintainability?" Not a raised eyebrow.\n\n## When NOT To Use Conversational Interviews\n\nThis format works best for senior, staff, and principal-level roles where judgment, systems thinking, and leadership matter more than implementation speed.\n\nFor **junior roles**, you need to verify fundamental skills. A coding exercise â even a simple one â is reasonable. You need to know they can write a function, debug an error, use basic data structures. A conversational interview would not produce enough signal at this level because junior candidates might not have enough experience to discuss.\n\nFor **mid-level roles**, a hybrid works well. A short practical task â maybe a small feature implementation or a debugging exercise â followed by a conversational segment about their approach and decisions.\n\nFor **high-volume pipelines** processing hundreds of applicants per week, conversational interviews do not work for initial screening. Use them in final rounds where signal quality matters most and the candidate pool is already filtered.\n\nAnd if you honestly cannot train your interviewers to run these well â if they will default to "just chatting" without structure or evaluation criteria â then a structured technical assessment with a clear rubric will give you more consistent, if less insightful, results. A mediocre conversational interview is worse than a well-run structured one.\n\n## How I Apply This\n\nWhen I interview senior candidates now, the first thing I say is: "This is not a test. I want to understand how you think about problems." You can visibly see people relax. The dynamic shifts from "exam" to "conversation." And that shift is where the good signal lives.\n\nI pick two or three scenarios from projects I have actually worked on â problems where I already know the trade-offs, the failure modes, the "it depends" factors. This lets me push on follow-ups authentically instead of working from a script.\n\nI pay close attention to how candidates handle what they do not know. Do they acknowledge the gap and think through it? Do they ask clarifying questions? Do they try to bluff? That single behavioral signal â comfort with uncertainty â correlates more strongly with real-world senior performance than any technical test I have seen.\n\nI always ask about AI. Always. The answer tells me whether they are adapting to how our craft is evolving or clinging to how it used to be. Both types of engineers can be effective, but I want to know which one I am hiring.\n\nThe best senior hires I have been part of all came through conversational interviews. The worst mismatch I ever witnessed was someone who aced every technical quiz but could not have a productive discussion about trade-offs in their first sprint. They knew all the answers. They just could not apply them to real problems.\n\n## Practical Recommendations\n\n**If you are an interviewer or hiring manager:**\n\n- Build a library of 5â8 scenario questions drawn from real challenges your team has faced. Rotate them across interviews.\n- Write a rubric with clear dimensions: problem decomposition, trade-off awareness, communication clarity, technical depth, collaboration instincts, leadership signal. Score each dimension independently.\n- Run calibration sessions at least quarterly. Have two or three interviewers evaluate the same mock candidate (or a recorded session) and compare scores. Discuss disagreements.\n- Let interviews run long if the conversation is productive. Cutting off a great discussion at exactly 60 minutes because the calendar says so is counterproductive.\n- Ask about AI openly. Normalize it. The candidates who are honest about their AI workflow are giving you more useful information than those who claim they write everything by hand.\n\n**If you are a candidate:**\n\n- When you do not remember an exact API or detail â say so, explain the concept, and describe how you would find it. This is what you would do at work. A good interviewer will not hold it against you. A bad one will, and that tells you something about the company.\n- Prepare stories, not answers. Think about real projects: problems you encountered, decisions you made, trade-offs you accepted, things you would do differently. Stories are memorable and specific. Rehearsed definitions are not.\n- Be honest about your AI workflow. If you use Copilot, Cursor, Claude, or any other tool heavily â own it. Explain how you review, validate, and maintain what gets generated. Hiding your actual workflow means the company is evaluating a version of you that does not exist.\n- Pay attention to how you are being interviewed. If it feels like a university exam â question after question, right or wrong, no discussion â that is a signal about the engineering culture. Use that information when you decide whether to accept or decline.\n- Ask your own questions aggressively. What is the team\'s architecture like? How are decisions made? What was the last big technical bet that did not work out? Your questions reveal your seniority more than your answers do.\n\n## Summary\n\nThe best technical interview I ever had involved zero algorithms, zero API trivia, and zero whiteboard coding. It was a two-h
1our conversation with a guy named John at a large Canadian automotive company, where he asked me real questions about real problems and followed every answer with "what if?" I learned more about how interviews should work from that single experience than from dozens of standard ones on both sides of the table.\n\nSenior interviews should test judgment. They should surface how a candidate thinks about ambiguity, trade-offs, team dynamics, and the realities of production systems. Conversational formats â scenario-based discussions, simple-sounding questions with deep follow-ups, collaborative problem-solving â do this better than any quiz.\n\nThey require better interviewers. They require shared rubrics and calibration. They take more time. But the trade-off is clear: you hire people who can actually do the job, not just people who can pass the test.\n\nIn an era where AI writes a significant chunk of our code, the thing that makes a senior engineer valuable is not what they can recite from memory. It is the quality of the decisions they make. Test for that.\n\n---\n\n*If you have thoughts on how senior interviews should work â or horror stories from the other side â [I\'d love to hear them](/contact).*\n'},{id:"ai-agent-project-context",title:"Designing Project Documentation for AI Coding Agents",excerpt:"How structured documentation dramatically improves AI-generated code quality by giving agents the context they need.",tags:["AI","Documentation","DX","Architecture"],date:"Mar 6, 2026",readTime:"12 min read",content:"\nI joined a project where everyone was writing code. Not just developers â project managers, analysts, even a designer who had never seen a terminal in his life. They would open VS Code, fire up Google's Gemini, and ask it to move a button, change a color, add border radius, tweak spacing. And the agent would do it. The code would work. And the codebase would get a little worse every time.\n\nBecause the agent had no context. It didn't know the project used a design system. It didn't know there were shared components for exactly these things. It didn't know that some files used CSS modules, others used inline styles, and a third group used something else entirely. So every AI-generated change was technically correct and architecturally wrong. The codebase turned into a patchwork of conflicting approaches.\n\nI spent almost two weeks writing structured project documentation specifically for the agent. Architecture, component rules, naming conventions, styling approach, the whole picture. And then I ran my own test: I told the agent, \"Find 20 components that have something to fix.\" That's it. A dead simple, almost primitive prompt. The agent â now armed with full project context â went through the codebase, found real issues (wrong styling approach, missing design tokens, inconsistent patterns), and fixed them on the spot. It was the moment I realized: the model was never the problem. The missing context was.\n\n## Context\n\nMost teams treat AI coding agents like extremely smart autocomplete. The agent sees the open files, maybe a README, and a prompt like \"implement this feature\".\n\nThat's rarely enough.\n\nIn real projects, the missing context is huge:\n\n- Why the product exists\n- Who the users are\n- Architectural constraints\n- Coding standards\n- Testing expectations\n- Future migration plans\n\nHumans fill these gaps from experience and project history. Agents don't.\n\nWhile working on enterprise projects (including work done for large companies such as Procter & Gamble and MedMe), I noticed the same pattern: agents generate better code when the project explicitly describes itself.\n\nThe solution that emerged was surprisingly simple.\n\nCreate a dedicated documentation layer designed for AI agents.\n\nNot a README.\nNot a wiki.\n\nA structured project context directory.\n\n## Core Concept\n\nThe core idea is straightforward:\n\nAI agents need explicit, structured context about the project.\n\nInstead of relying on scattered documentation, the project exposes its knowledge in a predictable location.\n\nExample:\n\n```\n/docs\n product.md\n architecture.md\n coding-standards.md\n testing-strategy.md\n code-review.md\n design-system.md\n roadmap.md\n third-party-services.md\n```\n\nAn index file explains what each document contains and when an agent should read it.\n\nExample:\n\n```\ndocs/index.md\n\nproduct.md\nShort overview of the product: users, business goals, main workflows.\n\narchitecture.md\nHigh-level architecture of the frontend and backend systems.\
1n\ncoding-standards.md\nProject conventions and linting rules the agent must follow.\n\ntesting-strategy.md\nWhat must be tested and how tests should be written.\n```\n\nThen the main instruction file references this folder.\n\nExample:\n\n```\nAGENT.md\n\nWhen working on this project you MUST read the docs folder.\n\nStart with:\ndocs/index.md\n\nBased on the task you may need to read:\n- architecture.md\n- coding-standards.md\n- testing-strategy.md\n- design-system.md\n```\n\nThis approach turns documentation into something closer to a context API for agents.\n\n## Implementation Example\n\nA simplified instruction file for an agent might look like this:\n\n```md\n# AGENT.md\n\nThis repository contains a React + Next.js application.\n\nBefore implementing any feature:\n1. Read docs/index.md\n2. Identify which documents are relevant for the task\n3. Follow project conventions strictly\n\nKey constraints:\n\n- Use TypeScript strict mode\n- Follow design tokens defined in docs/design-system.md\n- All components must have tests (see docs/testing-strategy.md)\n- Follow code review rules defined in docs/code-review.md\n```\n\nExample of coding standards exposed to the agent:\n\n```md\n# coding-standards.md\n\nComponent rules:\n\n- Components must be colocated with tests\n- Use function components only\n- Avoid default exports\n\nFolder structure:\n\ncomponents/\n Button/\n Button.tsx\n Button.test.tsx\n Button.styles.ts\n```\n\nExample of testing expectations:\n\n```md\n# testing-strategy.md\n\nAll UI components must include:\n\n- unit tests (Vitest)\n- interaction tests\n- accessibility checks where applicable\n\nExample:\n\ndescribe(\"Button\", () => {\n it(\"renders label\", () => {})\n it(\"triggers click handler\", () => {})\n})\n```\n\nOnce this context exists, agents consistently generate code that is much closer to what a real team would accept.\n\n## Integrating Task Context via MCP\n\nDocumentation alone is useful, but it becomes much more powerful when combined with external context.\n\nUsing MCP integrations, an agent can retrieve additional project history:\n\n- Jira tasks\n- Git commits\n- bug reports\n- previous discussions\n\nExample workflow:\n\n1. Agent works on a component\n2. It inspects commit history\n3. Extracts issue IDs\n4. Queries Jira via MCP\n5. Reads bug descriptions\n\nThis allows the agent to understand things like:\n\n- why a component was implemented in a specific way\n- what bugs previously occurred\n- which constraints must not be broken\n\nWithout this, agents tend to unknowingly reintroduce old bugs.\n\n## Where It Breaks in Enterprise\n\nThis approach works well, but it introduces new challenges.\n\n### Documentation drift\n\nDocs become outdated quickly if they are not maintained.\n\nAgents will follow outdated rules very confidently.\n\n### Over-documentation\n\nToo many documents create the opposite problem: agents get lost in irrelevant context.\n\nKeep documentation focused and scoped.\n\n### Missing architectural intent\n\nIf architecture documents only describe current implementation but not intent, agents will optimize locally and break long-term plans.\n\nFor example:\n\n- migrating from Redux to TanStack Query\n- replacing a design system\n- upgrading React versions\n\nAgents need to know about these plans.\n\n## Common Mistakes\n\n### Treating README as agent documentation\n\nREADME files are written for humans. They are narrative and incomplete.\n\nAgents work better with:\n\n- short\n- structured\n- explicit documentation.\n\n### Documenting only code structure\n\nAgents also need business context.\n\nExample:\n\n```\nUsers: pharmacists\nMain workflow: reviewing prescriptions\nCritical requirement: audit traceability\n```\n\nWithout this, agents optimize code but miss product constraints.\n\n### Not documenting code review expectations\n\nAgents can generate correct code that still fails review.\n\nFor example:\n\n- missing tests\n- wrong folder structure\n- inconsistent naming\n\nExplicit review rules prevent this.\n\n## When NOT To Use This\n\nThis approach is unnecessary for:\n\n- very small repositories\n- short-lived prototypes\n- single-developer experiments\n\nThe overhead only pays off when:\n\n- multiple developers collaborate\n- AI agents are heavily used\n- the project has architectural complexity.\n\n## How I Apply This in Real Projects\n\nIn projects where I use AI coding agents (primarily Claude-based tools, GPT assistants, and Gemini), I always start by creating a docs directory designed for the agent.\n\nThe process is simple:\n\n1. Write a short product overview\n2. Describe architecture at a high level\n3. Define coding and testing rules\n4. Document code review expectations\n5. Outline future architectural plans\n\nThe two-week documentation effort I described at the beginning of this article wasn't typical â that was a rescue operation for a codebase that had already accumulated months of context-free AI changes. For a new project, the initial setup takes a day or two. After that, you maintain it incrementally.\
1n\nThe \"find 20 components to fix\" trick has become my standard smoke test for new documentation. If the agent can autonomously identify and fix real issues based on the context you've written, the documentation is working. If it makes the same mistakes as before, the docs are missing something important.\n\n## Practical Recommendations\n\nIf you want to try this approach:\n\nStart small.\n\nCreate just four documents:\n\n```\ndocs/product.md\ndocs/architecture.md\ndocs/coding-standards.md\ndocs/testing-strategy.md\n```\n\nAdd an index file and reference it from your agent instructions.\n\nThen gradually expand documentation as real problems appear.\n\nAlso remember:\n\n- keep documents short\n- keep them structured\n- keep them updated.\n\nThink of them as system prompts for your codebase.\n\n## Summary\n\nThe project where non-developers were writing code through AI taught me something fundamental: models are already good enough. The bottleneck isn't intelligence â it's context. An agent without project documentation will confidently produce code that breaks every convention you have. An agent with structured context will find and fix problems you didn't even ask about.\n\nA structured docs/ directory can describe:\n\n- product context\n- architecture\n- coding standards\n- testing rules\n- code review expectations\n- future architectural plans\n\nCombined with integrations like Jira and Git history, this gives agents enough context to generate code that aligns with real production systems.\n\nIn practice, this simple change dramatically improves the quality of AI-generated code. Two weeks of documentation saved me months of cleanup.\n\n---\n\nIf you have feedback, alternative approaches, or just want to discuss this topic â feel free to reach out.\n"},{id:"nextjs-ssg-github-pages",title:"Next.js Static Export to GitHub Pages: A Production Setup Guide",excerpt:"How to configure Next.js 15 static export and deploy to GitHub Pages with a custom domain. Covers App Router, basePath, react-markdown blog pipeline, GitHub Actions CI/CD, and common pitfalls.",tags:["Next.js","GitHub Pages","SSG","CI/CD","DevOps"],date:"Feb 25, 2026",readTime:"10 min read",content:"\nStatic export remains the simplest deployment model for content-focused sites. This article documents the setup behind this very site â ma-x.im â a Next.js 15 project deployed to GitHub Pages.\n\nHonestly, this was my first static export project. Vercel exists and it's free too. But I wanted something dead simple â no platform dependencies, no vendor lock-in, just push and it's live. GitHub Pages felt like the most boring choice possible, and that was exactly the point. A few weeks in, I'm genuinely happy with it. I don't think about deployments, I don't debug infrastructure, it just works. Sometimes the boring choice is the right one.\n\n## Context\n\nThe goal: a personal site with static content and a blog, deployed to GitHub Pages with a custom domain. Requirements are low ops overhead, no monthly costs, and good reliability. GitHub Pages handles all of this well for static content.\n\nGitHub Pages constraints shape the architecture:\n\n**Static hosting only.** No server-side rendering, no API routes that execute at runtime, no middleware. Everything must be pre-rendered at build time.\n\n**Project Page path prefix.** If you deploy to `https://<user>.github.io/<repo>/`, all routes are prefixed with `/<repo>/`. This breaks absolute paths and requires explicit configuration. With a custom domain, this prefix disappears â the site serves at root.\n\n**No redirects or rewrites.** GitHub Pages serves files as-is. You cannot configure server-side redirects. Client-side routing works, but direct navigation to a non-existent file returns GitHub's 404 page unless you provide a `404.html`.\n\n**404 handling.** GitHub Pages serves `404.html` from the root when a file isn't found. Next.js static export generates this file from your `not-found.tsx`, but only if you have one.\n\n## Core Concept\n\n### Next.js SSG vs SSR/ISR\n\nNext.js supports multiple rendering strategies. For GitHub Pages, only Static Site Generation (SSG) works:\n\n- **SSR (Server-Side Rendering):** Renders pages on each request. Requires a Node.js server. Not compatible with static hosting.\n- **ISR (Incremental Static Regeneration):** Pre-renders at build, then revalidates on a schedule. Requires a server. Not compatible with static hosting.\n- **SSG (Static Site Generation):** Pre-renders all pages at build time. Outputs plain HTML/CSS/JS files. Works on any static host.\n\n### Static Export in Next.js 15\n\nIn Next.js 13+, static export is enabled via `output: 'export'` in your config. The build process generates a folder (default: `out/`) containing all HTML files, assets, and client-side JavaScript bundles.\n\nThis mode disables features that require a server runtime:\n- API routes (unless they're static JSON files)\n- Server Actions\n- Middleware\n- Dynamic rendering with `cookies()` or `headers()`\n- Redirects and rewrites in `next.config.ts`\n- Image Optimization API (requires a server)\n\n### basePath for Project Pages\n\nWhen deploying to `https://<user>.github.io/<repo>/`, the site lives at a subpath, not root. Without configuration, asset URLs and links break:\n\n```\nExpected: /styles/main.css\nActual location: /<repo>/styles/main.css\nâ 404\n```\n\nThe `basePath` config prepends the prefix to all routes and asset imports:\n\n```typescript\n// next.config.ts\nconst nextConfig: NextConfig = {\n basePath: '/my-repo',\n output: 'export',\n};\n```\n\nNext.js automatically handles:\n- `<Link href=\"/about\">` becomes `/<repo>/about`\n- Static imports and public folder assets\n- CSS/JS bundle paths\n\n**With a custom domain, basePath should not be set.** The domain points directly to the repo's Pages site, so routes serve at root.\n\n### trailingSlash\n\nControl
1s whether routes end with `/`:\n\n- `trailingSlash: false` â `/blog/my-post` (outputs `/blog/my-post.html`)\n- `trailingSlash: true` â `/blog/my-post/` (outputs `/blog/my-post/index.html`)\n\nGitHub Pages handles both, but `trailingSlash: true` is more predictable for some edge cases with direct file navigation. The trade-off is URL aesthetics. This repo uses `false`.\n\n### assetPrefix\n\nSeparate from `basePath`, `assetPrefix` controls where JS/CSS bundles are loaded from. Useful when assets are served from a CDN. For most GitHub Pages deployments, it's not needed â `basePath` handles everything.\n\n### next/image on Static Hosts\n\nThe default `next/image` component uses Next.js Image Optimization API, which requires a server. On static export, you must either:\n\n1. Set `images: { unoptimized: true }` to disable optimization and use raw image URLs\n2. Use a third-party loader (Cloudinary, Imgix, etc.)\n3. Avoid `next/image` entirely and use `<img>` tags\n\nThis repo uses native `<img>` tags with a custom fallback wrapper, avoiding the issue entirely.\n\n## Implementation Example\n\n### 1) Configure Next.js for static export (SSG)\n\nThe current `next.config.ts` in this repo:\n\n```typescript\nimport type { NextConfig } from 'next';\n\nconst nextConfig: NextConfig = {\n output: 'export',\n images: {\n unoptimized: true,\n },\n trailingSlash: false,\n};\n\nexport default nextConfig;\n```\n\nThis configuration:\n- Enables static export to `out/` directory\n- Disables image optimization (required for static hosting)\n- Uses clean URLs without trailing slashes\n\n**For a Project Page deployment** (without custom domain), add `basePath`:\n\n```typescript\n// Proposed change for Project Page deployment\nconst nextConfig: NextConfig = {\n output: 'export',\n basePath: '/ma-x.im', // Repository name\n images: {\n unoptimized: true,\n },\n trailingSlash: false,\n};\n```\n\n**Limitations to understand:**\n\nWith `output: 'export'`, these features throw build errors or are ignored:\n- `redirects` and `rewrites` in config\n- Middleware (`middleware.ts`)\n- API routes with runtime logic\n- Server Actions\n- `cookies()`, `headers()`, `searchParams` in Server Components (without `force-static`)\n\n### 2) Make routing and links survive basePath (Project Page)\n\n**The failure mode:** During development, the site runs at `localhost:3000/`. Links like `<Link href=\"/blog\">` work. After deploying to `https://user.github.io/repo/`, the same link points to `https://user.github.io/blog` (missing the repo prefix), returning 404.\n\n**How Next.js handles it:** When `basePath` is configured, `next/link` and `next/router` automatically prepend it. You don't need to change your code â just avoid hardcoded absolute paths.\n\nFrom this repo's `src/app/blog/page.tsx`:\n\n```tsx\nimport Link from 'next/link';\n\n// This works correctly with or without basePath\n<Link href={\\`/blog/\\${post.id}\\`}>\n <h2>{post.title}</h2>\n</Link>\n```\n\n**What breaks:**\n\n1. **Hardcoded absolute paths in JavaScript/CSS:**\n\n```tsx\n// Bad: Ignores basePath\nconst logoUrl = '/images/logo.png';\n\n// Good: Use relative paths or public folder imports\nimport logo from '@/public/images/logo.png';\n```\n\n2. **Metadata URLs (OpenGraph, Twitter, canonical):**\n\nFrom this repo's `src/app/blog/[slug]/page.tsx`:\n\n```tsx\nexport async function generateMetadata({ params }: BlogPostPageProps): Promise<Metadata> {\n const { slug } = await params;\n const post = blogPosts.find((p) => p.id === slug);\n\n return {\n openGraph: {\n url: \\`https://ma-x.im/blog/\\${post.id}\\`, // Hardcoded full URL\n },\n };\n}\n```\n\nThis works because the site uses a custom domain at root. For a Project Page, you'd need:\n\n```tsx\n// Proposed change for Project Page\nconst BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'https://user.github.io/repo';\n\nurl: \\`\\${BASE_URL}/blog/\\${post.id}\\`,\n```\n\n3. **Sitemap and RSS links:**\n\nThis repo generates a sitemap via `app/sitemap.ts`. All URLs must use the full absolute base. Note the `export const dynamic = 'force-static'` â it is required for `output: 'export'`, otherwise the build fail
1s:\n\n```typescript\n// app/sitemap.ts\nimport type { MetadataRoute } from 'next';\nimport { blogPosts } from '@/data/posts';\n\nexport const dynamic = 'force-static';\n\nconst BASE_URL = 'https://ma-x.im';\n\nexport default function sitemap(): MetadataRoute.Sitemap {\n const posts = blogPosts.map((post) => ({\n url: \\`\\${BASE_URL}/blog/\\${post.id}\\`,\n lastModified: new Date(post.date),\n changeFrequency: 'monthly' as const,\n priority: 0.7,\n }));\n\n return [\n { url: BASE_URL, lastModified: new Date(), changeFrequency: 'monthly', priority: 1 },\n { url: \\`\\${BASE_URL}/blog\\`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.8 },\n { url: \\`\\${BASE_URL}/contact\\`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.5 },\n ...posts,\n ];\n}\n```\n\nNew blog posts are picked up automatically â the sitemap is built from the same `blogPosts` array used by the blog pages.\n\nPair the sitemap with `app/robots.ts` so search engines can discover it. Same rule applies â `export const dynamic = 'force-static'` is required:\n\n```typescript\n// app/robots.ts\nimport type { MetadataRoute } from 'next';\n\nexport const dynamic = 'force-static';\n\nexport default function robots(): MetadataRoute.Robots {\n return {\n rules: {\n userAgent: '*',\n allow: '/',\n },\n sitemap: 'https://ma-x.im/sitemap.xml',\n };\n}\n```\n\n### 3) Blog pipeline: Markdown + react-markdown (repo-specific)\n\nThis repo stores blog posts as TypeScript files with embedded markdown content, not as `.md` files. This approach has trade-offs:\n\n**Advantages:**\n- Type safety on post metadata\n- No build-time file system reading\n- Posts are bundled with the application\n\n**Disadvantages:**\n- Content editing requires code changes\n- Larger JavaScript bundles if content grows\n- No separation between content and code\n\n**Post structure** in `src/data/posts/types.ts`:\n\n```typescript\nexport interface BlogPost {\n id: string;\n title: string;\n excerpt: string;\n tags: string[];\n date: string;\n readTime: string;\n content: string; // markdown content\n}\n```\n\n**Static route generation** in `src/app/blog/[slug]/page.tsx`:\n\n```tsx\nimport { blogPosts } from '@/data/posts/index';\n\n// Generate static pages for all posts at build time\nexport async function generateStaticParams() {\n return blogPosts.map((post) => ({\n slug: post.id,\n }));\n}\n\n// Return 404 for slugs not in the list (required for output: 'export')\nexport const dynamicParams = false;\n```\n\nThe `dynamicParams = false` is critical. Without it, Next.js assumes dynamic routes might be handled at runtime â incompatible with static export.\n\n**Rendering markdown** via `src/components/MarkdownContent.tsx`:\n\n```tsx\n'use client';\n\nimport ReactMarkdown from 'react-markdown';\nimport remarkGfm from 'remark-gfm';\nimport { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';\nimport { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';\n\nexport function MarkdownContent({ content }: { content: string }) {\n return (\n <ReactMarkdown\n remarkPlugins={[remarkGfm]}\n components={{\n code: ({ className, children, ...props }: any) => {\n const match = /language-(\\w+)/.exec(className || '');\n const codeString = String(children).replace(/\\n$/, '');\n\n if (match) {\n return (\n <SyntaxHighlighter style={oneDark} language={match[1]} PreTag=\"div\">\n {codeString}\n </SyntaxHighlighter>\n );\n }\n\n return <code className=\"px-2 py-0.5 bg-gray-100 rounded text-sm\" {...props}>{children}</code>;\n },\n }}\n >\n {content}\n </ReactMarkdown>\n );\n}\n```\n\n**Common pitfalls:**\n\n1. **Missing `generateStaticParams`:** Dynamic routes without static params fail on export with a build error.\n\n2. **Forgetting `dynamicParams = false`:** The build succeeds, but routes not in `generateStaticParams` cause errors.\n\n3. **Runtime file system access:** If you read `.md` files with `fs`, ensure it happens only in `generateStaticParams` or `generateMetadata`, not in component render functions. In App Router, this works correctly since those functions run at build time.\n\n4. **Syntax highlighter SSR issues:** Libraries like `react-syntax-highlighter` can have hydration mismatches. Marking the component with `'use client'` and ensuring consistent rendering helps.\n\n### 4) GitHub Actions deployment to GitHub Pages\n\nThe existing workflow at `.github/workflows/deploy.yml`:\n\n```yaml\nname: Deploy to GitHub Pages\n\non:\n push:\n branches: [master, new-version-v2]\n workflow_dispatch:\n\npermissions:\n contents: read\n pages: write\n id-token: write\n\nconcurrency:\n group: \"pages\"\n cancel-in-progress: false\n\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - name: Checkout\n uses: actions/checkout@v4\n\n - name: Setup Node.js\n uses: actions/setup-node@v4\n with:\n node-version: '20'\n cache: 'npm'\n\n - name: Install dependencies\n run: npm ci\n\n - name: Lint\n run: npm run lint\n\n - name: Type check\n run: npx tsc --noEmit\n\n - name: Build\n run: npm run build\n\n - name: Upload artifact\n uses: actions/upload-pages-artifact@v3\n with:\n path: ./out\n\n deploy:\n environment:\n name: github-pages\n url: ${{ steps.deployment.outputs.page_url }}\n runs-on: ubuntu-latest\n needs: build\n steps:\n - name: Deploy to GitHub Pages\n id: deployment\n uses: actions/deploy-pages@v4\n```\n\n**Key elements:**\n\n1. **Permissions:*
1* `pages: write` and `id-token: write` are required for the official deploy action. `contents: read` allows checkout.\n2. **Lint and type check before build:** Catches issues before producing artifacts. Lint validates code style, `tsc --noEmit` verifies types without emitting files.\n3. **Concurrency:** Prevents overlapping deployments with `cancel-in-progress: false` (lets the current deployment finish rather than canceling mid-deploy).\n4. **Artifact upload:** The `upload-pages-artifact` action packages `./out` (Next.js static export directory) for deployment.\n5. **Two-job structure:** Build and deploy are separate jobs. The deploy job waits for build with `needs: build`.\n6. **Environment:** The `github-pages` environment enables GitHub's deployment tracking and status checks.\n\n**Repo settings checklist:**\n\n1. Navigate to repository Settings â Pages\n2. Under \"Build and deployment\", set Source to \"GitHub Actions\"\n3. Do not select a branch â the workflow handles deployment\n4. If using a custom domain, enter it under \"Custom domain\"\n5. Enforce HTTPS (checkbox)\n\n### 5) Custom domain on GitHub Pages\n\nThis repo uses the custom domain `ma-x.im`. Setup involves three parts:\n\n**1. CNAME file in the repository:**\n\nThe file `public/CNAME` contains:\n\n```\nma-x.im\n```\n\nThis file must exist in the deployed output. Since it's in `public/`, Next.js copies it to `out/` during build. The GitHub Pages deploy action then includes it in the deployment artifact.\n\n**2. Repository settings:**\n\nIn Settings â Pages â Custom domain, enter `ma-x.im`. GitHub validates DNS and provisions an SSL certificate.\n\n**3. DNS configuration:**\n\nFor an apex domain like `ma-x.im`, configure DNS at your registrar:\n\n**Option A: A records (IPv4)**\n```\nA @ 185.199.108.153\nA @ 185.199.109.153\nA @ 185.199.110.153\nA @ 185.199.111.153\n```\n\n**Option B: ALIAS/ANAME record (if supported)**\n```\nALIAS @ <user>.github.io\n```\n\nFor a subdomain like `www.ma-x.im`:\n```\nCNAME www <user>.github.io\n```\n\n**HTTPS certificate provisioning:**\n\nAfter DNS propagates, GitHub automatically requests a certificate from Let's Encrypt. This can take minutes to hours depending on DNS propagation. During this time, the site may show a certificate warning.\n\n**Keeping CNAME stable:**\n\nThe current setup (CNAME in `public/`) works correctly. An alternative for repos where you can't commit to `public/`:\n\n```yaml\n - name: Build\n run: npm run build\n\n - name: Add CNAME\n run: echo \"ma-x.im\" > ./out/CNAME\n\n - name: Upload artifact\n uses: actions/upload-pages-artifact@v3\n with:\n path: ./out\n```\n\n### 6) Local verification and \"it works on my machine\" traps\n\nAfter building, verify the static output locally:\n\n```bash\n# Build the site\nnpm run build\n\n# Serve the output directory\nnpx serve out\n```\n\nThis serves at `http://localhost:3000` by default. All routes should work, including direct navigation to `/blog/building-design-systems-that-scale`.\n\n**Testing basePath locally:**\n\nIf deploying as a Project Page with `basePath: '/my-repo'`, the local test needs to simulate the path prefix:\n\n```bash\nnpx serve out -l 3000\n# Then access at http://localhost:3000/my-repo/\n```\n\n**\"Works locally, broken in production\" scenarios:**\n\n1. **Missing CNAME:** Site deploys but custom domain doesn't work.\n2. **Absolute paths in code:** Work at root locally, break with basePath or different domain.\n3. **Environment variables:** `NEXT_PUBLIC_*` variables are baked in at build time. If you test with `.env.local` but CI doesn't have the same values, behavior differs.\n4. **Browser caching:** Hard refresh or incognito mode when debugging deployment issues.\n\n## Where It Breaks in Enterprise\n\nStatic export works for content sites but hits walls in enterprise contexts:\n\n**No server features.** Auth, personalization, A/B testing, and anything requiring runtime logic must move to external services or client-side JavaScript.\n\n**Preview and drafts.** Editorial workflows typically need preview deployments of unpublished content. Static export requires rebuilding for every preview â doable but slow.\n\n**Search.** Client-side search works for small sites (load a JSON index, search in JavaScript). For larger content, you need an external search service.\n\n**Comments.** Disqus, Giscus, or similar third-party embeds. No server means no self-hosted comment storage.\n\n**Build time scaling.** As content grows, build times grow. A 1000-post blog might take 10+ minutes to build. This is manageable but affects development velocity.\n\n**Image optimization.** Without the Next.js optimization API, you handle image resizing externally or serve unoptimized assets. On fast connections, this is fine. On mobile, it hurts.\n\n**Multi-locale with basePath.** Supporting multiple languages with static export works but gets complex with custom domains per locale or locale prefixes combined with basePath.\n\n**Analytics and privacy.** Client-side analytics (Google Analytics, Plausible) work. Server-side analytics (for privacy) require a proxy or separate service.\n\n## Common Mistakes\n\n**Forgetting basePath for Project Pages.** Routes and assets return 404. The site looks broken on GitHub but works locally.\n\n**Using next/image without `unoptimized: true`.** Build fail
1s with an error about Image Optimization requiring a server.\n\n**Expecting redirects/rewrites to work.** They're silently ignored on static export. Use client-side navigation or restructure URLs.\n\n**Missing `generateStaticParams` for dynamic routes.** Build error: \"Page with `output: export` has dynamic route but no `generateStaticParams`.\"\n\n**Using `dynamicParams = true` with static export.** Build might succeed but unknown slugs cause issues. Always set `dynamicParams = false`.\n\n**Runtime environment variables.** `process.env.MY_VAR` in client components doesn't work â it's replaced at build time. Use `NEXT_PUBLIC_` prefix for client-visible values.\n\n**Deploying the wrong folder.** Next.js outputs to `out/` by default, but if you forget to check the workflow, you might deploy `.next/` or the root folder.\n\n**Broken canonical/OG URLs.** Hardcoded URLs that don't account for basePath or custom domain result in wrong social previews and SEO issues.\n\n## When NOT To Use This\n\nStatic export to GitHub Pages is wrong when:\n\n- You need authentication or user sessions\n- Content is personalized per user\n- You need middleware (geo-redirects, A/B tests at the edge)\n- You need API routes with runtime logic\n- Build times exceed acceptable CI limits (hundreds of pages or more)\n- You need ISR for frequently changing content\n\nFor these cases, consider Vercel, Netlify, or Cloudflare Pages â platforms with server/edge support that handle these features natively with Next.js.\n\n## How I Apply This in Real Projects\n\nFor personal sites and blogs, I keep the stack minimal: static export, GitHub Pages, and a custom domain. No external services beyond what's required.\n\nI automate the deployment pipeline once and don't touch it. The workflow in this repo hasn't changed significantly since initial setup.\n\nI intentionally don't build: comments, search (for small sites), analytics (unless required), or complex CMS integrations. Each adds maintenance burden disproportionate to value for personal projects.\n\nWhen a project genuinely needs server features, I move to a platform that supports them rather than working around static export limitations.\n\n## Practical Recommendations\n\n**Config checklist:**\n- `output: 'export'` in `next.config.ts`\n- `basePath` set if deploying to `/<repo>/` (omit for custom domain)\n- `images: { unoptimized: true }` if using `next/image`\n- `trailingSlash` set based on URL preference\n- `dynamicParams = false` on all dynamic routes\n\n**Content pipeline:**\n- `generateStaticParams` returns all valid slugs\n- Metadata URLs use full absolute URLs for social sharing\n- CNAME file in `public/` for custom domains\n\n**CI/CD checks:**\n- Lint runs before build (`npm run lint`)\n- Type check runs before build (`npx tsc --noEmit`)\n- Build artifact is correct directory (`out/`)\n- Workflow triggers on correct branches\n\n**Regression tests:**\n- Manually verify a few routes after deployment\n- Check 404 page appears for invalid routes\n- Verify assets load (images, CSS, JS)\n- Test OG images in social share debuggers\n\n## Summary\n\nNext.js static export to GitHub Pages is a production-ready approach for content sites. It's what powers this site, and after running it for a while I can say the setup is genuinely low-maintenance.\n\nThe key decisions â `output: 'export'`, `images: { unoptimized: true }`, `generateStaticParams` with `dynamicParams = false` â address the common failure modes. Understanding the constraints (no server runtime, basePath for Project Pages, explicit static generation for dynamic routes) saves you from most surprises.\n\nThe approach scales well for small to medium sites. For larger content needs or server-dependent features, migration to a full hosting platform is the right move rather than fighting static export limitations.\n\nIf you have feedback, alternative approaches, or just want to discuss this topic â feel free to reach out.\n "},{id:"building-design-systems-that-scale",title:"Building Design Systems That Scale",excerpt:"How to build component libraries that teams actually want to use.",tags:["Design Systems","React","Architecture"],date:"Jan 15, 2026",readTime:"8 min read",content:"\nAt Motorola Solutions I walked into a codebase with hundreds of internal projects. Every team followed the brand book â in theory. In practice, it was chaos. Buttons looked different everywhere. Navigation behaved differently in every app. Everyone thought they were on-brand. Nobody actually was.\n\nThey asked me to help build a unified de
1sign system. Nine months of work, tight collaboration between designers and developers. The result: a component library that snapped together like Lego. You didn't think about padding, font sizes, spacing â everything just worked out of the box. We shipped variants for React and Angular simultaneously. The designers had specced the components so precisely that they composed into each other without friction.\n\nThen came adoption. Migration was painful. Versioning became its own nightmare â but I'll get to that.\n\nBuilding a design system is the easy part. Getting teams to actually use it is where most fail.\n\n## The Problem\n\nI've watched multiple design system efforts die. Not because the components were poorly designed, but because they didn't solve the right problems at the right time. Teams would build beautiful libraries, document everything meticulously, and then watch as nobody used them.\n\n## Start with Real Problems\n\nThe best design systems I've built started with actual pain points:\n\n- **Inconsistent UI patterns** across features\n- **Slow feature development** due to rebuilding common components\n- **Accessibility gaps** that were hard to track\n- **Design-dev handoff friction** that slowed everything down\n\n## Key Principles\n\n### 1. Component API Design Matters\n\nYour components should be **easy to use correctly** and **hard to use incorrectly**.\n\n```tsx\n// Bad: Too many props, unclear usage\n<Button \n variant=\"primary\" \n size=\"md\" \n loading={false} \n disabled={false}\n onClick={handleClick}\n/>\n\n// Good: Clear defaults, composable\n<Button onClick={handleClick}>\n Click me\n</Button>\n\n<Button variant=\"secondary\" loading>\n Loading...\n</Button>\n```\n\n### 2. Documentation is Your Product\n\nNobody reads long documentation. Make it:\n\n- **Visual** - Show examples first\n- **Interactive** - Let people play with props\n- **Searchable** - Find what you need fast\n- **Practical** - Real use cases, not toy examples\n\nI use Storybook for this. Every component gets:\n- Multiple usage examples\n- Props documentation\n- Accessibility notes\n- Do's and don'ts\n\n### 3. Build for Adoption\n\nThe best design system is the one people use. Focus on:\n\n**Developer Experience:**\n- TypeScript support out of the box\n- Clear error messages\n- Intuitive prop names\n- Good defaults\n\n**Performance:**\n- Tree-shakeable exports\n- Lazy loading for heavy components\n- Optimized bundle size\n\n**Flexibility:**\n- Composable components\n- Escape hatches when needed\n- Theme customization\n\n## The Tech Stack\n\nHere's what I typically use:\n\n- **React + TypeScript** - Type safety is non-negotiable\n- **Tailwind CSS** - Utility-first scaling, easy theming\n- **Radix UI** - Accessible primitives\n- **Storybook** - Component documentation\n- **Turborepo** - Monorepo management\n- **Changesets** - Version management\n\n## Common Pitfalls\n\n### Over-engineering Early\n\nDon't build 50 components on day one. Start with:\n1. Buttons\n2. Inputs\n3. Layout primitives\n4. Typography system\n\nAdd more as real needs emerge.\n\n### Ignoring Migration Path\n\nAt Motorola Solutions, migration was the hardest part. Hundreds of projects, each with their own component variations. We couldn't flip a switch. Some teams adopted early, others dragged their feet for months. Eventually every project had the same interface, the same navigation, the same look â and honestly, that felt great. But then versioning caught up with us: some teams locked to an early version and never updated. When they finally did â months later â things broke because the API had evolved. We spent weeks helping teams catch up. If I could redo one thing, it would be investing more in automated migration tooling from day one.\n\nIf you're replacing an old system, make migration gradual:\n- Support both old and new side-by-side\n- Provide codemods where possible\n- Document migration guides\n- Track adoption metrics\n\n### Skipping Accessibility\n\nBuild it in from day one. Every component should:\n- Work with keyboard navigation\n- Have proper ARIA labels\n- Support screen readers\n- Handle focus management\n\n## Measuring Success\n\nTrack these metrics:\n\n1. **Adoption rate** - % of features using the system\n2. **Component coverage** - % of UI built with system components\n3. **Development velocity
1** - Time to build new features\n4. **Consistency score** - How many UI patterns exist\n\n## Conclusion\n\nAfter the Motorola Solutions project, I started seeing design systems differently. The system itself isn't the product â adoption is the product. The best component library in the world is worthless if teams work around it instead of using it. Ours was far from perfect, but it worked because designers and developers built it together, and because the components were genuinely easier to use than rolling your own.\n\nStart small. Iterate based on real feedback from real teams. And always prioritize the developer experience â because if it's easier to build from scratch than to use your system, that's exactly what people will do.\n\n---\n\n*Have questions about building design systems? [Reach out](/contact) â I've been through this and happy to share more.*\n "},{id:"type-safe-react-architecture",title:"Type-Safe React Architecture",excerpt:"Leveraging TypeScript for better developer experience and fewer runtime errors.",tags:["TypeScript","React","DX"],date:"Dec 28, 2025",readTime:"12 min read",content:'\nI remember the exact moment I realized I couldn\'t go back. I had been writing TypeScript for a while, got used to it, and then joined a project that was pure JavaScript. No types. No autocompletion for props. No compile-time errors. Just... hope.\n\nIt was unbearable. How do you know what this function returns? You don\'t â you read the implementation. What props does this component accept? You check the source. Did someone rename a field in the API response? You\'ll find out in production.\n\nThe thing about TypeScript is that you don\'t see the bugs it prevents. You introduce strict types and your code just... works more reliably. You never get the dramatic "TypeScript saved us from a production outage" moment because the bug never made it past your editor. It stays invisible. But go back to a project without it, and you feel the absence immediately.\n\nTypeScript isn\'t just about catching bugs. It\'s about building systems that are **impossible to misuse**.\n\n## Why Type Safety Matters\n\nAfter 20+ years of engineering, I\'ve learned: the best code is code that guides you toward correct usage.\n\n### The Cost of Runtime Errors\n\nRuntime errors in production are expensive:\n- **User frustration** - Broken features hurt trust\n- **Debug time** - Finding issues in production is slow\n- **Lost revenue** - Crashes during checkout cost money\n- **Team velocity** - Fear of breaking things slows development\n\nTypeScript moves these errors to **compile time**.\n\n## Architecture Patterns\n\n### 1. Strict Type Configuration\n\nStart with the strictest possible `tsconfig.json`:\n\n```json\n{\n "compilerOptions": {\n "strict": true,\n "noUncheckedIndexedAccess": true,\n "noImplicitReturns": true,\n "noFallthroughCasesInSwitch": true,\n "exactOptionalPropertyTypes": true\n }\n}\n```\n\nYes, it\'s painful at first. But it catches **so many bugs**.\n\n### 2. Component Props as Contracts\n\nYour component props are API contracts:\n\n```tsx\n// Weak typing\ninterface ButtonProps {\n variant?: string;\n size?: string;\n onClick?: Function;\n}\n\n// Strong typing\ntype ButtonVariant = "primary" | "secondary" | "ghost";\ntype ButtonSize = "sm" | "md" | "lg";\n\ninterface ButtonProps {\n variant?: ButtonVariant;\n size?: ButtonSize;\n onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;\n children: React.ReactNode;\n}\n```\n\nNow your IDE will autocomplete variants and catch typos.\n\n### 3. Discriminated Unions for State\n\nHandle complex state with discriminated unions:\n\n```tsx\ntype FetchState<T> =\n | { status: "idle" }\n | { status: "loading" }\n | { status: "success"; data: T }\n | { status: "error"; error: Error };\n\nfunction UserProfile() {\n const [state, setState] = useState<FetchState<User>>({\n status: "idle",\n });\n\n // TypeScript knows what properties exist in each branch\n if (state.status === "success") {\n return <div>{state.data.name}</div>; // â
data exists\n }\n\n if (state.status === "error") {\n return <div>{state.error.message}</div>; // â
error exists\n }\n\n return <Loading />;\n}\n```\n\nThis pattern **eliminates entire classes of bugs**.\n\n### 4. Generic Components Done Right\n\nMake your components reusable without losing type safety:\n\n```tsx\ninterface SelectOption<T> {\n value: T;\n label: string;\n}\n\ninterface SelectProps<T> {\n options: SelectOption<T>[];\n value: T;\n onChange: (value: T) => void;\n}\n\nfunction Select<T>({ options, value, onChange }: SelectProps<T>
1) {\n return (\n <select\n value={String(value)}\n onChange={(e) => {\n const option = options.find(\n (opt) => String(opt.value) === e.target.value\n );\n if (option) onChange(option.value);\n }}\n >\n {options.map((opt) => (\n <option key={String(opt.value)} value={String(opt.value)}>\n {opt.label}\n </option>\n ))}\n </select>\n );\n}\n\n// Usage - fully type-safe!\n<Select<UserRole>\n options={[\n { value: "admin", label: "Admin" },\n { value: "user", label: "User" },\n ]}\n value={role}\n onChange={setRole} // TypeScript knows this is UserRole\n/>\n```\n\n### 5. Form Validation with Zod\n\nCombine React Hook Form with Zod for bulletproof forms:\n\n```tsx\nimport { z } from "zod";\nimport { useForm } from "react-hook-form";\nimport { zodResolver } from "@hookform/resolvers/zod";\n\nconst userSchema = z.object({\n email: z.string().email("Invalid email"),\n age: z.number().min(18, "Must be 18+"),\n role: z.enum(["admin", "user"]),\n});\n\ntype UserFormData = z.infer<typeof userSchema>;\n\nfunction UserForm() {\n const { register, handleSubmit, formState: { errors } } = \n useForm<UserFormData>({\n resolver: zodResolver(userSchema),\n });\n\n const onSubmit = (data: UserFormData) => {\n // data is fully typed and validated!\n console.log(data);\n };\n\n return (\n <form onSubmit={handleSubmit(onSubmit)}>\n <input {...register("email")} />\n {errors.email && <span>{errors.email.message}</span>}\n </form>\n );\n}\n```\n\n**One schema**, both runtime validation and TypeScript types.\n\n## API Integration\n\nType your API responses:\n\n```tsx\n// Define your API types\ninterface User {\n id: string;\n name: string;\n email: string;\n}\n\ninterface ApiResponse<T> {\n data: T;\n error?: string;\n}\n\n// Type-safe API client\nasync function fetchUser(id: string): Promise<User> {\n const response = await fetch(`/api/users/${id}`);\n const json: ApiResponse<User> = await response.json();\n \n if (json.error) {\n throw new Error(json.error);\n }\n \n return json.data;\n}\n\n// Use with TanStack Query\nconst { data: user } = useQuery({\n queryKey: ["user", id],\n queryFn: () => fetchUser(id),\n});\n\n// user is typed as User | undefined\n```\n\n## Testing Benefits\n\nTypeScript makes testing easier:\n\n```tsx\nimport { render, screen } from "@testing-library/react";\n\n// TypeScript catches missing props\nrender(<Button>Click me</Button>); // â
\n\n// @ts-expect-error - variant must be valid\nrender(<Button variant="invalid">Click me</Button>); // â\n```\n\n## Developer Experience Wins\n\nWith proper TypeScript setup:\n\n1. **Refactoring is safe** - Rename a prop, find all usages instantly\n2. **Autocomplete everywhere** - Your IDE knows what\'s valid\n3. **Self-documenting code** - Types explain usage\n4. **Faster onboarding** - New devs understand APIs from types\n5. **Fewer PR comments** - Type checker catches issues\n\n## Common Mistakes\n\n### Using `any`\n\nNever use `any`. Use `unknown` if you must:\n\n```tsx\n// Bad\nfunction processData(data: any) {\n return data.value; // No safety\n}\n\n// Good\nfunction processData(data: unknown) {\n if (typeof data === "object" && data !== null && "value" in data) {\n return (data as { value: string }).value;\n }\n throw new Error("Invalid data");\n}\n```\n\n### Ignoring Errors\n\nDon\'t `@ts-ignore` your way out of problems. Fix the types.\n\n### Over-complicating Types\n\nKeep it simple. Complex types are hard to maintain.\n\n## Conclusion\n\nEvery time I join a project that doesn\'t have TypeScript, my first instinct is to add it. Not because I enjoy configuring `tsconfig.json` â but because I\'ve experienced the difference too many times. The bugs you don\'t see. The refactors that just work. The onboarding where new developers understand APIs from reading types instead of hoping.\n\nType safety isn\'t optional for serious React applications. It\'s the foundation of reliable software, great DX, and confident refactoring. Invest in your type system. Your future self will thank you.\n\n---\n\n*Want to discu
1ss TypeScript architecture? [Let\'s connect](/contact).*\n '},{id:"complex-forms-done-right",title:"Complex Forms Done Right",excerpt:"Patterns for managing complex form state, validation, and user experience.",tags:["Forms","React Hook Form","Architecture"],date:"Dec 10, 2025",readTime:"10 min read",content:'\nAt Motorola Solutions I built a multi-step contract wizard where users selected products, services, additional options, and pricing tiers â dozens of fields that depended on each other. It wasn\'t just a form. It was a live calculator that recalculated totals, toggled conditional sections, and validated business rules at every step.\n\nLater, at an insurance company, I worked on a policy builder that asked everything from property type and vehicle model to how many pets you own and how long you\'ve had them. Every answer changed what came next. Every combination produced a different quote.\n\nForms like these are where most implementations fall apart. A simple login form? Easy. A multi-step wizard with conditional fields, async validation, dynamic pricing, and file uploads? That\'s a different engineering problem entirely.\n\n## The Challenge\n\nComplex forms have multiple concerns:\n- **State management** - Field values, touched, dirty states\n- **Validation** - Sync and async, field-level and form-level\n- **UX** - When to show errors, loading states, success feedback\n- **Performance** - Re-renders, large forms, dynamic fields\n- **Accessibility** - Screen readers, keyboard navigation, error announcements\n\n## The Right Tools\n\nAfter building dozens of complex forms, here\'s my stack:\n\n- **React Hook Form** - Uncontrolled forms, great performance\n- **Zod** - Schema validation, type inference\n- **TanStack Query** - Async validation, mutations\n- **Radix UI** - Accessible form primitives\n\n## Architecture Patterns\n\n### 1. Schema-First Design\n\nDefine your form schema first:\n\n```tsx\nimport { z } from "zod";\n\nconst addressSchema = z.object({\n street: z.string().min(1, "Street is required"),\n city: z.string().min(1, "City is required"),\n country: z.string().min(1, "Country is required"),\n postalCode: z.string().regex(/^d{5}$/, "Invalid postal code"),\n});\n\nconst userSchema = z.object({\n email: z.string().email("Invalid email"),\n password: z\n .string()\n .min(8, "At least 8 characters")\n .regex(/[A-Z]/, "Need uppercase letter")\n .regex(/[0-9]/, "Need a number"),\n confirmPassword: z.string(),\n address: addressSchema,\n terms: z.literal(true, {\n errorMap: () => ({ message: "You must accept terms" }),\n }),\n}).refine((data) => data.password === data.confirmPassword, {\n message: "Passwords don\'t match",\n path: ["confirmPassword"],\n});\n\ntype UserFormData = z.infer<typeof userSchema>;\n```\n\n**One schema** defines:\n- Field types\n- Validation rules\n- Error messages\n- TypeScript types\n\n### 2. Async Validation\n\nCheck email availability without blocking the UI:\n\n```tsx\nconst emailSchema = z.string().email().refine(\n async (email) => {\n const response = await fetch(`/api/check-email?email=${email}`);\n const { available } = await response.json();\n return available;\n },\n { message: "Email already taken" }\n);\n```\n\nWith React Hook Form:\n\n```tsx\n<input\n {...register("email", {\n validate: async (value) => {\n const result = await emailSchema.safeParseAsync(value);\n return result.success || result.error.errors[0].message;\n },\n })}\n/>\n```\n\n### 3. Multi-Step Forms\n\nBreak complex forms into steps:\n\n```tsx\nfunction MultiStepForm() {\n const [step, setStep] = useState(1);\n const methods = useForm<UserFormData>();\n\n const onSubmit = async (data: UserFormData) => {\n if (step < 3) {\n setStep(step + 1);\n return;\n }\n\n // Final submission\n await createUser(data);\n };\n\n return (\n <FormProvider {...methods}>\n <form onSubmit={methods.handleSubmit(onSubmit)}>\n {step === 1 && <PersonalInfoStep />}\n {step === 2 && <AddressStep />}\n {step === 3 && <ReviewStep />}\n\n <div>\n {step > 1 && (\n <button type="button" onClick={() => setStep(step - 1)}>\n Back\n </button>\n )}\n <button type="submit">\n {step < 3 ? "Next" : "Submit"}\n </button>\n </div>\n </form>\n </FormProvider>\n );\n}\n```\n\n### 4. Dynamic Field Arrays\n\nAdd/remove fields dynamically:\n\n```tsx\nimport { useFieldArray } from "react-hook-form";\n\nfunction SkillsForm() {\n const { control, register } = useForm();\n const { fields, append, remove } = useFieldArray({\n control,\n name: "skills",\n });\n\n return (\n <div>\n {fields.map((field, index) => (\n <div key={field.id}>\n <input\n {...register(`skills.${index}.name`)}\n placeholder="Skill name"\n />\n <input\n type="number"\n {...register(`skills.${index}.years`)}\n placeholder="Years"\n />\n <button type="button" onClick={() => remove(index)}>\n Remove\n </button>\n </div>\n ))}\n \n <button\n type="button"\n onClick={() => append({ name: "", years: 0 })}\n >\n Add Skill\n </button>\n </div>\n );\n}\n```\n\n### 5. Conditional Fields\n\nShow/hide fields based on other values:\n\n```tsx\nfunction PaymentForm(
1) {\n const { register, watch } = useForm();\n const paymentMethod = watch("paymentMethod");\n\n return (\n <div>\n <select {...register("paymentMethod")}>\n <option value="card">Credit Card</option>\n <option value="bank">Bank Transfer</option>\n </select>\n\n {paymentMethod === "card" && (\n <>\n <input {...register("cardNumber")} placeholder="Card Number" />\n <input {...register("cvv")} placeholder="CVV" />\n </>\n )}\n\n {paymentMethod === "bank" && (\n <>\n <input {...register("accountNumber")} placeholder="Account" />\n <input {...register("routingNumber")} placeholder="Routing" />\n </>\n )}\n </div>\n );\n}\n```\n\n## Error Handling UX\n\nShow errors at the right time:\n\n```tsx\nfunction SmartInput({ name, label, ...props }) {\n const {\n register,\n formState: { errors, touchedFields, isSubmitted },\n } = useFormContext();\n\n const error = errors[name];\n const showError = error && (touchedFields[name] || isSubmitted);\n\n return (\n <div>\n <label htmlFor={name}>{label}</label>\n <input\n id={name}\n aria-invalid={showError ? "true" : "false"}\n aria-describedby={showError ? `${name}-error` : undefined}\n {...register(name)}\n {...props}\n />\n {showError && (\n <span id={`${name}-error`} role="alert">\n {error.message}\n </span>\n )}\n </div>\n );\n}\n```\n\nOnly show errors after:\n1. User has touched the field, OR\n2. Form has been submitted\n\nThis prevents angry red errors while typing.\n\n## Performance Optimization\n\nFor large forms (50+ fields):\n\n```tsx\nconst { register } = useForm({\n mode: "onBlur", // Validate on blur, not on change\n shouldUnregister: true, // Unregister unmounted fields\n});\n```\n\nUse `Controller` only for complex inputs:\n\n```tsx\nimport { Controller } from "react-hook-form";\n\n<Controller\n name="birthDate"\n control={control}\n render={({ field }) => (\n <DatePicker\n value={field.value}\n onChange={field.onChange}\n />\n )}\n/>\n```\n\n## File Uploads\n\nHandle files properly:\n\n```tsx\nfunction FileUpload() {\n const { register, watch } = useForm();\n const file = watch("avatar");\n\n return (\n <div>\n <input\n type="file"\n accept="image/*"\n {...register("avatar")}\n />\n {file?.[0] && (\n <img\n src={URL.createObjectURL(file[0])}\n alt="Preview"\n />\n )}\n </div>\n );\n}\n```\n\n## Submission with TanStack Query\n\nClean mutation handling:\n\n```tsx\nfunction UserForm() {\n const methods = useForm<UserFormData>();\n const mutation = useMutation({\n mutationFn: createUser,\n onSuccess: () => {\n toast.success("User created!");\n methods.reset();\n },\n onError: (error) => {\n toast.error(error.message);\n },\n });\n\n const onSubmit = (data: UserFormData) => {\n mutation.mutate(data);\n };\n\n return (\n <form onSubmit={methods.handleSubmit(onSubmit)}>\n {/* fields */}\n <button type="submit" disabled={mutation.isPending}>\n {mutation.isPending ? "Saving..." : "Save"}\n </button>\n </form>\n );\n}\n```\n\n## Accessibility Checklist\n\nâ
Labels for every input\nâ
Error messages announced to screen readers\nâ
Keyboard navigation works\nâ
Focus management (auto-focus first error)\nâ
Required fields marked\nâ
Clear validation feedback\n\n## Testing\n\nTest forms thoroughly:\n\n```tsx\nimport { render, screen, waitFor } from "@testing-library/react";\nimport userEvent from "@testing-library/user-event";\n\ntest("shows validation errors", async () => {\n render(<UserForm />);\n \n const user = userEvent.setup();\n const submitButton = screen.getByRole("button", { name: /submit/i });\n \n await user.click(submitButton);\n \n await waitFor(() =>
1 {\n expect(screen.getByText(/email is required/i)).toBeInTheDocument();\n });\n});\n```\n\n## Conclusion\n\nThe Motorola contract wizard and the insurance policy builder taught me the same lesson: complex forms aren\'t a UI problem â they\'re a data modeling problem. Get the schema right first, handle validation at the schema level, and the rest follows.\n\nSchema-first validation. Smart error timing. Performance-aware rendering. Accessibility from day one. Thorough testing. Get these right, and even the most complex forms become manageable.\n\n---\n\n*Need help with complex forms? [Get in touch](/contact).*\n '},{id:"react-performance-optimization",title:"React Performance Optimization",excerpt:"Practical techniques for building fast React applications at scale.",tags:["React","Performance","Optimization"],date:"Nov 22, 2025",readTime:"15 min read",content:'\nI joined a large Canadian automotive company as one of three senior frontend engineers. The site was public-facing â a product catalog where users browsed vehicle models, configurations, and pricing. Built on Sitecore with React components on top.\n\nThe first thing I noticed: the catalog page re-rendered on every interaction. Not just the part you touched â everything. The page pulled data from separate endpoints for vehicle types, models, specifications, and pricing. Every state change triggered a cascade of re-renders across all of them. It was the first time in my career I saw genuine, visible lag on a production public website â not some internal admin panel, but a site real customers used every day.\n\nThat became our first priority. We spent weeks profiling, restructuring data flow, and in some cases rewriting components from scratch because the original code was unsalvageable. The performance boost was dramatic and became our biggest early win with the team.\n\nThat experience taught me something I keep coming back to: performance isn\'t about premature optimization. It\'s about building systems that stay fast as they grow.\n\n## When to Optimize\n\nDon\'t optimize everything. Optimize when:\n- Users report slowness\n- Metrics show issues (Core Web Vitals)\n- Profiling reveals bottlenecks\n- You\'re building data-heavy features\n\n**Measure first, optimize second.**\n\n## Profiling Tools\n\nUse the right tools:\n\n1. **React DevTools Profiler** - See component render times\n2. **Chrome Performance** - Analyze full page performance\n3. **Web Vitals** - Track real user metrics\n4. **Lighthouse** - Automated audits\n\n## Common Performance Issues\n\n### 1. Unnecessary Re-renders\n\n**Problem:** Component re-renders when props haven\'t changed.\n\n```tsx\n// Bad: Creates new object every render\nfunction Parent() {\n return <Child config={{ theme: "dark" }} />;\n}\n\n// Good: Stable reference\nconst CONFIG = { theme: "dark" };\n\nfunction Parent() {\n return <Child config={CONFIG} />;\n}\n\n// Or use useMemo for dynamic values\nfunction Parent() {\n const config = useMemo(() => ({ theme: "dark" }), []);\n return <Child config={config} />;\n}\n```\n\n**Use `React.memo` wisely:**\n\n```tsx\nconst ExpensiveComponent = React.memo(({ data }) => {\n // Heavy rendering logic\n return <div>{/* ... */}</div>;\n});\n```\n\n### 2. Large Lists Without Virtualization\n\n**Problem:** Rendering 10,000 rows kills performance.\n\n**Solution:** Use virtualization.\n\n```tsx\nimport { useVirtualizer } from "@tanstack/react-virtual";\n\nfunction VirtualList({ items }) {\n const parentRef = useRef<HTMLDivElement>(null);\n\n const virtualizer = useVirtualizer({\n count: items.length,\n getScrollElement: () => parentRef.current,\n estimateSize: () => 50,\n });\n\n return (\n <div ref={parentRef} style={{ height: "500px", overflow: "auto" }}>\n <div\n style={{\n height: `${virtualizer.getTotalSize()}px`,\n position: "relative",\n }}\n >\n {virtualizer.getVirtualItems().map((virtualRow) => (\n <div\n key={virtualRow.index}\n style={{\n position: "absolute",\n top: 0,\n left: 0,\n width: "100%",\n height: `${virtualRow.size}px`,\n transform: `translateY(${virtualRow.start}px)`,\n }}\n >\n {items[virtualRow.index].name}\n </div>\n ))}\n </div>\n </div>\n );\n}\n```\n\nOnly renders visible items. Huge performance win.\n\n### 3. Heavy Computations in Render\n\n**Problem:** Expensive calculations on every render.\n\n```tsx\n// Bad: Recalculates every render\nfunction DataTable({ data }) {\n const sorted = data.sort((a, b) => a.value - b.value);\n const filtered = sorted.filter(item => item.active);\n \n return <Table data={filtered} />;\n}\n\n// Good: Memoized\nfunction DataTable({ data }) {\n const processed = useMemo(() =>
1 {\n const sorted = [...data].sort((a, b) => a.value - b.value);\n return sorted.filter(item => item.active);\n }, [data]);\n \n return <Table data={processed} />;\n}\n```\n\n### 4. Inline Functions as Props\n\n**Problem:** New function every render = child re-renders.\n\n```tsx\n// Bad\nfunction Parent() {\n return (\n <Child onClick={() => console.log("clicked")} />\n );\n}\n\n// Good\nfunction Parent() {\n const handleClick = useCallback(() => {\n console.log("clicked");\n }, []);\n \n return <Child onClick={handleClick} />;\n}\n```\n\n### 5. Context Provider Re-renders\n\n**Problem:** Context value changes = all consumers re-render.\n\n```tsx\n// Bad: New object every render\nfunction ThemeProvider({ children }) {\n const [theme, setTheme] = useState("light");\n \n return (\n <ThemeContext.Provider value={{ theme, setTheme }}>\n {children}\n </ThemeContext.Provider>\n );\n}\n\n// Good: Memoized value\nfunction ThemeProvider({ children }) {\n const [theme, setTheme] = useState("light");\n \n const value = useMemo(() => ({ theme, setTheme }), [theme]);\n \n return (\n <ThemeContext.Provider value={value}>\n {children}\n </ThemeContext.Provider>\n );\n}\n```\n\n## Code Splitting\n\nLoad code only when needed:\n\n```tsx\nimport { lazy, Suspense } from "react";\n\nconst Dashboard = lazy(() => import("./Dashboard"));\nconst Settings = lazy(() => import("./Settings"));\n\nfunction App() {\n return (\n <Suspense fallback={<Loading />}>\n <Routes>\n <Route path="/dashboard" element={<Dashboard />} />\n <Route path="/settings" element={<Settings />} />\n </Routes>\n </Suspense>\n );\n}\n```\n\n## Image Optimization\n\nImages are usually the biggest assets:\n\n```tsx\n// Use modern formats\n<img\n src="hero.webp"\n srcSet="hero-sm.webp 400w, hero-md.webp 800w, hero-lg.webp 1200w"\n sizes="(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px"\n alt="Hero"\n loading="lazy"\n/>\n```\n\nUse `loading="lazy"` for below-the-fold images.\n\n## State Management Performance\n\n### Use Zustand for Better Performance\n\n```tsx\nimport { create } from "zustand";\n\nconst useStore = create((set) => ({\n todos: [],\n addTodo: (todo) => set((state) => ({\n todos: [...state.todos, todo]\n })),\n}));\n\n// Only re-renders when todos change\nfunction TodoList() {\n const todos = useStore((state) => state.todos);\n return <div>{/* ... */}</div>;\n}\n\n// Never re-renders\nfunction AddTodo() {\n const addTodo = useStore((state) => state.addTodo);\n return <button onClick={() => addTodo({ text: "New" })}>Add</button>;\n}\n```\n\n### TanStack Query for Server State\n\n```tsx\nconst { data } = useQuery({\n queryKey: ["users"],\n queryFn: fetchUsers,\n staleTime: 5 * 60 * 1000, // 5 minutes\n});\n```\n\nAutomatic caching, deduplication, background refetching.\n\n## Bundle Size Optimization\n\n### Tree Shaking\n\n```tsx\n// Bad: Imports entire library\nimport _ from "lodash";\n\n// Good: Import only what you need\nimport debounce from "lodash/debounce";\n```\n\n### Analyze Bundle\n\n```bash\nnpm install --save-dev vite-plugin-bundle-analyzer\n```\n\nFind and remove large dependencies.\n\n## React Compiler (Coming Soon)\n\nReact 19+ will have automatic memoization. But until then, manual optimization is needed.\n\n## Performance Checklist\n\nâ
Profile before optimizing\nâ
Use React.memo for expensive components\nâ
Virtualize large lists\nâ
Code split routes\nâ
Lazy load images\nâ
Optimize images (WebP, sizing)\nâ
Memoize expensive computations\nâ
Use proper state management\nâ
Analyze bundle size\nâ
Monitor Core Web Vitals\n\n## Conclusion\n\nAfter the automotive catalog project, I never look at performance the same way. When it\'s bad, users feel it immediately â and they leave. When you fix it, the numbers speak for themselves. Every project where I\'ve invested in performance early paid it back many times over.\n\nMeasure with real tools. Profile before you guess. Fix the actual bottlenecks, not the ones you imagine. And keep monitoring in production â performance degrades silently if nobody\'s watching.\n\n---\n\n*Need help with React performance? [Let\'
1s talk](/contact).*\n '},{id:"monorepo-best-practices",title:"Monorepo Best Practices",excerpt:"How to structure and manage large-scale monorepo projects effectively.",tags:["Monorepo","Architecture","DevOps"],date:"Nov 5, 2025",readTime:"11 min read",content:'\nI learned to love monorepos the hard way â by suffering through the alternative.\n\nAt a large Canadian automotive company, we had a repository for basic UI components, a separate repository for complex components that depended on them, and then six separate repositories â one for each product line: cars, motorcycles, engines, marine, power equipment, and more. Plus utility repos for shared configs, linting rules, and deployment scripts.\n\nWhen you released a patch to the base components, the chain reaction began. Update base components, then update complex components, then update all six product repos. Something always broke somewhere in that chain. A CI job would hang. A version mismatch would surface. A team using an older version would suddenly discover their build was broken. We outsourced CI/CD to external contractors, which added another layer of friction â every issue turned into a cross-team investigation.\n\nWe even started preparing a plan to consolidate everything into a single monorepo. I left the company before they finished.\n\nThat experience made me a firm believer: if your projects share code, put them together.\n\n## Why Monorepo?\n\n**Benefits:**\n- **Shared code** â one source of truth for components, utils\n- **Atomic changes** â update library and consumers in one PR\n- **Better refactoring** â see all usages across projects\n- **Unified tooling** â one config for linting, testing, building\n- **Easier onboarding** â everything in one repo\n\n**Drawbacks:**\n- **Slower CI** if not optimized\n- **More complex tooling** needed\n- **Harder access control** (everything visible)\n\nFor most teams, benefits outweigh drawbacks. And for teams that have lived through multi-repo dependency hell â it\'s not even a question.\n\n## Tools\n\nI use **Turborepo**. It\'s fast, simple, and works great with npm/pnpm.\n\nAlternatives:\n- **Nx** - More features, steeper learning curve\n- **Lerna** - Older, less maintained\n- **Rush** - Microsoft\'s tool, great for large teams\n\n## Structure\n\nClean structure is critical:\n\n```\nmy-monorepo/\nâââ apps/\nâ âââ web/ # Main web app\nâ âââ admin/ # Admin dashboard\nâ âââ mobile/ # React Native app\nâââ packages/\nâ âââ ui/ # Shared component library\nâ âââ utils/ # Shared utilities\nâ âââ config/ # Shared configs (eslint, ts)\nâ âââ types/ # Shared TypeScript types\nâââ tooling/\nâ âââ eslint-config/\nâ âââ typescript-config/\nâââ package.json\nâââ turbo.json\nâââ pnpm-workspace.yaml\n```\n\n**Apps** = Deployable applications\n**Packages** = Shared libraries\n**Tooling** = Dev tools and configs\n\n## Package Setup\n\nEach package has its own `package.json`:\n\n```json\n{\n "name": "@repo/ui",\n "version": "0.0.0",\n "private": true,\n "main": "./src/index.ts",\n "types": "./src/index.ts",\n "exports": {\n ".": "./src/index.ts",\n "./button": "./src/button.tsx"\n }\n}\n```\n\nApps import packages:\n\n```tsx\nimport { Button } from "@repo/ui";\nimport { formatDate } from "@repo/utils";\n```\n\nTypeScript resolves these via workspace protocol.\n\n## Turborepo Configuration\n\n`turbo.json`:\n\n```json\n{\n "tasks": {\n "build": {\n "dependsOn": ["^build"],\n "outputs": ["dist/**", ".next/**"]\n },\n "test": {\n "dependsOn": ["^build"]\n },\n "lint": {},\n "dev": {\n "cache": false,\n "persistent": true\n }\n }\n}\n```\n\n**Key features:**\n- `dependsOn` - Build dependencies first\n- `outputs` - Cache these directories\n- `cache: false` - Don\'t cache dev server\n\n## Workspace Protocol\n\nUse `pnpm` for best monorepo support:\n\n`pnpm-workspace.yaml`:\n\n```yaml\npackages:\n - "apps/*"\n - "packages/*"\n - "tooling/*"\n```\n\nInstall workspace dependencies:\n\n```json\n{\n "depen
1dencies": {\n "@repo/ui": "workspace:*",\n "@repo/utils": "workspace:*"\n }\n}\n```\n\n## Shared Configuration\n\nShare ESLint config:\n\n```js\n// tooling/eslint-config/index.js\nmodule.exports = {\n extends: [\n "eslint:recommended",\n "plugin:@typescript-eslint/recommended",\n "plugin:react/recommended",\n ],\n // ... rules\n};\n```\n\nUse in packages:\n\n```json\n{\n "eslintConfig": {\n "extends": ["@repo/eslint-config"]\n }\n}\n```\n\nSame pattern for TypeScript, Prettier, Jest configs.\n\n## CI/CD Optimization\n\n**Problem:** CI runs all tests on every commit, even if code didn\'t change.\n\n**Solution:** Turborepo\'s remote caching.\n\n```bash\n# Only rebuild what changed\nturbo run build --filter=[HEAD^1]\n\n# Use remote cache\nturbo run build --cache-dir=.turbo/cache\n```\n\nGitHub Actions example:\n\n```yaml\nname: CI\n\non: [pull_request]\n\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v3\n \n - uses: pnpm/action-setup@v2\n with:\n version: 8\n \n - name: Setup Node\n uses: actions/setup-node@v3\n with:\n node-version: 18\n cache: \'pnpm\'\n \n - run: pnpm install\n \n - name: Build\n run: pnpm turbo run build --filter=[HEAD^1]\n \n - name: Test\n run: pnpm turbo run test --filter=[HEAD^1]\n```\n\n**Huge time savings** on large PRs.\n\n## Versioning Strategy\n\nFor internal packages (not published to npm):\n\n```json\n{\n "version": "0.0.0",\n "private": true\n}\n```\n\nFor published packages, use **Changesets**:\n\n```bash\npnpm add -DW @changesets/cli\npnpm changeset init\n```\n\nCreate changeset:\n\n```bash\npnpm changeset\n```\n\nIt asks:\n- Which packages changed?\n- Semver bump (major/minor/patch)?\n- Changelog entry?\n\nThen in CI:\n\n```bash\npnpm changeset version\npnpm changeset publish\n```\n\nAutomatic versioning and publishing.\n\n## Common Pitfalls\n\n### 1. Not Using Task Dependencies\n\n```json\n{\n "tasks": {\n "build": {\n "dependsOn": ["^build"] // Build deps first!\n }\n }\n}\n```\n\nWithout this, apps might build before their dependencies.\n\n### 2. Circular Dependencies\n\n**Never** create circular dependencies:\n\n```\nâ @repo/ui -> @repo/utils -> @repo/ui\n```\n\nKeep dependency graph acyclic.\n\n### 3. Too Many Small Packages\n\nDon\'t create a package for every util function. Group related code:\n\n```\nâ
@repo/utils (contains formatDate, parseUrl, etc.)\nâ @repo/format-date, @repo/parse-url, ...\n```\n\n### 4. Ignoring Cache Configuration\n\nConfigure `outputs` properly:\n\n```json\n{\n "tasks": {\n "build": {\n "outputs": ["dist/**", "build/**", ".next/**"]\n }\n }\n}\n```\n\nMissing this = no caching benefits.\n\n## Testing in Monorepo\n\nShared Jest config:\n\n```js\n// packages/jest-config/jest.config.js\nmodule.exports = {\n preset: "ts-jest",\n testEnvironment: "jsdom",\n moduleNameMapper: {\n "^@repo/(.*)$": "<rootDir>/../$1/src",\n },\n};\n```\n\nRun all tests:\n\n```bash\nturbo run test\n```\n\nRun tests for specific package:\n\n```bash\nturbo run test --filter=@repo/ui\n```\n\n## Development Workflow\n\nStart all apps in dev mode:\n\n```bash\nturbo run dev --parallel\n```\n\nWork on specific app:\n\n```bash\nturbo run dev --filter=web\n```\n\nWatch mode for package:\n\n```bash\nturbo run dev --filter=@repo/ui\n```\n\n## Migration Strategy\n\nMoving to monorepo? Do it gradually:\n\n1. **Setup monorepo structure**\n2. **Move shared code** to packages\n3. **Migrate one app** at a time\n4. **Update CI/CD** incrementally\n5. **Train team** on new workflows\n\nDon\'t do big-bang migrations. Too risky.\n\n## Conclusion\n\nEvery time I set up a monorepo from scratch, I think about those six separate repos and the hours we spent chasing version mismatches across them. The initial monorepo setup takes effort â no question. But the alternative is worse: scattered code, broken dependency chains, and a CI pipeline that nobody fully understands.\n\nGet the structure right. Invest in proper tooling. Cache aggressively. And keep your dependency graph clean. Future you will be grateful.\n\n---\n\n*Questions about monorepos? [Reach out](/contact).*\n '}].filter(function(e){return!1!==e.published}).sort((e,t)=>
1new Date(t.date).getTime()-new Date(e.date).getTime());let p=[{id:"foundation",title:"Foundation",description:"Set the project up so it can grow without collapsing under its own weight.",slugs:["react-playbook-starting-new-project","react-playbook-typescript-patterns","react-playbook-code-structure","react-playbook-error-handling"]},{id:"data-and-navigation",title:"Data & Navigation",description:"Move data through the app and let people move through the app.",slugs:["react-playbook-data-fetching","react-playbook-tanstack-router","react-playbook-reatom-state-management","react-playbook-immutable-data-structures"]},{id:"ui-and-forms",title:"UI & Forms",description:"The parts users actually touch â styling, components, forms, and charts.",slugs:["react-playbook-css-styling","react-playbook-ui-libraries","react-playbook-form-libraries","react-playbook-chart-libraries"]},{id:"infrastructure",title:"Infrastructure",description:"Auth, a backend, rendering, a database, hosting, and tests that keep it honest.",slugs:["react-playbook-authentication","react-playbook-backend","react-playbook-ssr-tanstack-start","react-playbook-database-turso","react-playbook-hosting-railway","react-playbook-testing"]},{id:"advanced",title:"Advanced Topics",description:"The features that turn a project into a product â and the ones people underestimate.",slugs:["react-playbook-ai","react-playbook-websockets","react-playbook-i18n","react-playbook-payments","react-playbook-time","react-playbook-file-upload","react-playbook-mails"]},{id:"ecosystem",title:"Ecosystem",description:"Where React goes beyond the browser â design, docs, desktop, mobile, and 3D.",slugs:["react-playbook-design-prototyping","react-playbook-component-documentation","react-playbook-desktop","react-playbook-react-native","react-playbook-vr-ar"]},{id:"architecture-at-scale",title:"Architecture at Scale",description:"The boundaries, patterns, and decisions that decide whether an app stays readable as it grows.",slugs:["react-playbook-shared-code","react-playbook-barrel-files","react-playbook-module-dependencies","react-playbook-composition-and-di","react-playbook-container-presentational"]},{id:"component-patterns",title:"Component Patterns",description:"The reusable-component techniques that decide whether what you build gets used or quietly rewritten.",slugs:["react-playbook-compound-components","react-playbook-headless-components","react-playbook-changing-shared-components","react-playbook-ui-kit-versioning"]},{id:"state-and-data-at-scale",title:"State & Data at Scale",description:"Where state actually lives, what persists, and how to keep one coherent copy of the truth.",slugs:["react-playbook-local-storage-and-state","react-playbook-data-normalization","react-playbook-optimistic-ui","react-playbook-undo-redo","react-playbook-state-machines"]},{id:"communication-and-events",title:"Communication & Events",description:"How the parts of an app talk to each other without turning into untraceable, stringly-typed magic.",slugs:["react-playbook-event-bus","react-playbook-feature-flags","react-playbook-analytics-as-a-layer"]},{id:"performance-and-architecture",title:"Performance & Architecture",description:"Optimizing where it actually hurts â without trading away the structure that keeps the app maintainable.",slugs:["react-playbook-rerenders-and-architecture"]},{id:"look-and-feel",title:"Look & Feel Architecture",description:"The polish that turns out to be a boundary decision â theming, commands, and the systems behind the surface.",slugs:["react-playbook-theming-dark-mode","react-playbook-command-palette","react-playbook-code-splitting"]},{id:"async-and-resilience",title:"Async & Resilience",description:"Work that outlives the component that started it â cancelling, timing out, and staying consistent.",slugs:["react-playbook-abort-controller"]},{id:"types-and-tradeoffs",title:"Types & Tradeoffs",description:"Where type safety stops paying for itself, and how to tell before the whole team is stuck.",slugs:["react-playbook-types-that-earn-their-keep"]},{id:"architecture-styles",title:"Architecture Styles",description:"FSD, layered, and micro-frontends are not competing religions â they are different bets on what changes.",slugs:["react-playbook-what-fsd-actually-fixed","react-playbook-layered-vs-feature-sliced","react-playbook-micro-frontends-org-chart"]}].flatMap(e=>e.slugs).length,m=[{id:"cost-and-control",title:"Cost & Control",description:"Why more agents is not the goal, and what runs away if nobody is watching the meter.",slugs:["agent-playbook-you-dont-need-multi-agents","agent-playbook-do-it-as-usual","agent-playbook-token-bill","agent-playbook-test-on-change","agent-playbook-guardrails"]},{id:"context-and-judgment",title:"Context & Judgment",description:"What an agent can see, what it can do, and when it should stop and ask instead of acting.",slugs:["agent-playbook-context-is-the-product","agent-playbook-tools-are-intelligence","agent-playbook-when-to-ask","agent-playbook-memory-vs-context","agent-playbook-evaluation-is-the-missing-loop"]},{id:"upkeep",title:"Upkeep",description:"The part nobody schedules: keeping an agent's own rules honest as the project moves on.",slugs:["agent-playbook-instructions-rot"]}].flatMap(e=>e.slugs).length,y=[{id:"why-architecture-at-all",title:"Why Architecture at All",description:'Frontend spent years being treated as "not real architecture." This is the case that it is.',slugs:["smeared-component","react-playbook-code-structure","react-playbook-what-fsd-actually-fixed","react-playbook-layered-vs-feature-sliced","react-playbook-micro-frontends-org-chart","react-playbook-shared-code","react-playbook-module-dependencies","react-playbook-barrel-files"]},{id:"boundaries-and-composition",title:"Boundaries & Composition",description:"Where one piece ends and another begins, and how that boundary survives contact with a deadline.",slugs:["react-playbook-composition-and-di","react-playbook-container-presentational","react-playbook-compound-components","react-playbook-headless-components","react-playbook-changing-shared-components","react-playbook-ui-kit-versioning"]},{id:"state-and-data-architecture",title:"State & Data Architecture",description:"Where state actually lives, what persists, and how far you can push it before it turns to mud.",slugs:["react-playbook-data-normalization","react-playbook-local-storage-and-state","react-playbook-optimistic-ui","react-playbook-undo-redo","react-playbook-state-machines","react-playbook-immutable-data-structures","react-playbook-reatom-state-management"]},{id:"communication-and-cross-cutting",title:"Communication & Cross-Cutting Concerns",description:"How the parts of an app talk to each other without turning into untraceable, stringly-typed magic.",slugs:["react-playbook-event-bus","react-playbook-feature-flags","react-playbook-analytics-as-a-layer"]}
1,{id:"rendering-and-delivery",title:"Rendering & Delivery",description:"Decisions that look like performance tuning but are actually architecture wearing a disguise.",slugs:["react-playbook-ssr-tanstack-start","react-playbook-code-splitting","react-playbook-rerenders-and-architecture","react-playbook-theming-dark-mode","react-playbook-command-palette","react-playbook-types-that-earn-their-keep","react-playbook-authentication"]},{id:"real-systems-real-constraints",title:"Real Systems, Real Constraints",description:"The same ideas under the weight of an actual product, an actual team, and an actual deadline.",slugs:["building-design-systems-that-scale","complex-forms-done-right","monorepo-best-practices","react-playbook-design-prototyping","checkout-architecture-time-to-goal"]}].flatMap(e=>e.slugs).length,g=[{href:"/playbook",label:"The React Playbook",description:"Empty folder to production, in ".concat(p," stops.")},{href:"/agent-playbook",label:"The Agent Playbook",description:"Working with AI coding agents, sensibly â ".concat(m," parts.")},{href:"/architecture-playbook",label:"The Architecture Playbook",description:"".concat(y," architecture-focused articles, regrouped by concern.")}];function f(){let[e,t]=(0,u.useState)(!1),[n,o]=(0,u.useState)(!1),[i,p]=(0,u.useState)(!1),m=(0,u.useRef)(null),y=(0,d.useRouter)(),f=(0,d.usePathname)();(0,u.useEffect)(()=>{if(!n)return;let e=e=>{m.current&&!m.current.contains(e.target)&&o(!1)};return document.addEventListener("mousedown",e),()=>document.removeEventListener("mousedown",e)},[n]);let w=e=>{if("/"!==f){y.push("/#".concat(e)),t(!1);return}let n=document.getElementById(e);n&&(n.scrollIntoView({behavior:"smooth"}),t(!1))};return(0,a.jsx)("header",{className:"sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-gray-200",children:(0,a.jsxs)("div",{className:"max-w-7xl mx-auto px-6 lg:px-8",children:[(0,a.jsxs)("div",{className:"flex items-center justify-between h-16",children:[(0,a.jsx)("button",{onClick:()=>{"/"!==f?y.push("/"):window.scrollTo({top:0,behavior:"smooth"}),t(!1)},className:"text-xl tracking-tight hover:opacity-70 transition-opacity font-bold cursor-pointer",children:"ma-x.im"}),(0,a.jsxs)("nav",{className:"hidden lg:flex items-center gap-8",children:[(0,a.jsxs)("div",{className:"flex items-center gap-4",children:[(0,a.jsx)(s(),{href:"/frontend-architecture-review",className:"text-sm font-semibold text-gray-900 hover:text-[#15803d] transition-colors",children:"Architecture Review"}),(0,a.jsx)("span",{className:"w-px h-4 bg-gray-300","aria-hidden":"true"}),(0,a.jsx)(s(),{href:"/blog",className:"text-sm font-semibold text-[#16a34a] hover:text-[#15803d] transition-colors",children:"Blog"}),(0,a.jsx)("span",{className:"w-px h-4 bg-gray-300","aria-hidden":"true"}),(0,a.jsxs)("div",{className:"relative",ref:m,children:[(0,a.jsxs)("button",{onClick:()=>o(e=>!e),className:"inline-flex items-center gap-1 text-sm font-semibold text-[#16a34a] underline decoration-2 decoration-[#22c55e] underline-offset-4 hover:text-[#15803d] transition-colors","aria-expanded":n,children:["Playbooks",(0,a.jsx)(r,{size:14,className:"transition-transform ".concat(n?"rotate-180":"")})]}),n&&(0,a.jsx)("div",{className:"absolute left-0 top-full mt-2 w-72 rounded-2xl border border-gray-200 bg-white shadow-lg p-2 z-50",children:g.map(e=>(0,a.jsxs)(s(),{href:e.href,onClick:()=>o(!1),className:"block rounded-xl px-3 py-2.5 hover:bg-gray-50 transition-colors",children:[(0,a.jsx)("span",{className:"block text-sm font-semibold text-gray-900",children:e.label}),(0,a.jsx)("span",{className:"block text-xs text-gray-500 mt-0.5",children:e.description})]},e.href))})]})]}),(0,a.jsx)("button",{onClick:()=>w("about"),className:"text-sm text-gray-600 hover:text-gray-900 transition-colors",children:"About"}),(0,a.jsx)("button",{onClick:()=>w("skills"),className:"text-sm text-gray-600 hover:text-gray-900 transition-colors",children:"Skills"}),(0,a.jsx)("button",{onClick:()=>w("portfolio"),className:"text-sm text-gray-600 hover:text-gray-900 transition-colors",children:"Portfolio"}),(0,a.jsx)("button",{onClick:()=>w("contact"),className:"text-sm text-gray-600 hover:text-gray-900 transition-colors",children:"Contact"}),(0,a.jsxs)("a",{href:"/images/KuzmitskyiMaksym-CV.pdf",download:!0,className:"inline-flex items-center gap-2 px-4 py-2 bg-[#22c55e] text-white rounded-xl hover:bg-[#16a34a] transition-all hover:shadow-lg hover:shadow-green-500/25 text-sm",children:[(0,a.jsx)(h.A,{size:16}),"Download CV"]})]}),(0,a.jsx)("button",{onClick:()=>t(!e),className:"lg:hidden p-2 text-gray-600 hover:text-gray-900",children:e?(0,a.jsx)(l,{size:24}):(0,a.jsx)(c,{size:24})})]}),e&&(0,a.jsx)("div",{className:"lg:hidden py-4 border-t border-gray-200",children:(0,a.jsxs)("div",{className:"flex flex-col gap-4",children:[(0,a.jsx)(s(),{href:"/frontend-architecture-review",onClick:()=>t(!1),className:"text-left text-sm font-semibold text-gray-900 hover:text-[#15803d] transition-colors py-2",children:"Architecture Review"}),(0,a.jsx)(s(),{href:"/blog",onClick:()=>t(!1),className:"text-left text-sm font-semibold text-[#16a34a] hover:text-[#15803d] transition-colors py-2",children:"Blog"}),(0,a.jsxs)("div",{children:[(0,a.jsxs)("button",{onClick:()=>p(e=>!e),className:"w-full flex items-center justify-between text-left text-sm font-semibold text-[#16a34a] underline decoration-2 decoration-[#22c55e] underline-offset-4 hover:text-[#15803d] transition-colors py-2","aria-expanded":i,children:["Playbooks",(0,a.jsx)(r,{size:14,className:"transition-transform ".concat(i?"rotate-180":"")})]}),i&&(0,a.jsx)("div",{className:"pl-3 flex flex-col gap-1 pb-2",children:g.map(e=>(0,a.jsx)(s(),{href:e.href,onClick:()=>t(!1),className:"text-left text-sm text-gray-700 hover:text-[#15803d] transition-colors py-1.5",children:e.label},e.href))})]}),(0,a.jsx)("button",{onClick:()=>w("about"),className:"text-left text-sm text-gray-600 hover:text-gray-900 transition-colors py-2",children:"About"}),(0,a.jsx)("button",{onClick:()=>w("skills"),className:"text-left text-sm text-gray-600 hover:text-gray-900 transition-colors py-2",children:"Skills"}),(0,a.jsx)("button",{onClick:()=>w("portfolio"),className:"text-left text-sm text-gray-600 hover:text-gray-900 transition-colors py-2",children:"Portfolio"}),(0,a.jsx)("button",{onClick:()=>w("contact"),className:"text-left text-sm text-gray-600 hover:text-gray-900 transition-colors py-2",children:"Contact"}),(0,a.jsxs)("a",{href:"/images/KuzmitskyiMaksym-CV.pdf",download:!0,className:"inline-flex items-center justify-center gap-2 px-4 py-2 bg-[#22c55e] text-white rounded-xl hover:bg-[#16a34a] transition-all hover:shadow-lg hover:shadow-green-500/25 text-sm",children:[(0,a.jsx)(h.A,{size:16}),"Download CV"]})]})})]})})}},5786:()=>{},6907:(e,t,n)=>{Promise.resolve().then(n.bind(n,3073)),Promise.resolve().then(n.t.bind(n,5786,23))}
1,9143:(e,t,n)=>{"use strict";n.d(t,{A:()=>a});let a=(0,n(2302).A)("arrow-down-to-line",[["path",{d:"M12 17V3",key:"1cwfxf"}],["path",{d:"m6 11 6 6 6-6",key:"12ii2o"}],["path",{d:"M19 21H5",key:"150jfl"}]])}},e=>{var t=t=>e(e.s=t);e.O(0,[523,874,441,684,358],()=>t(6907)),_N_E=e.O()}]);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.