The stack behind Troco, and what it changed for our team
How TanStack Form, Zod, Zustand, Tailwind CSS and NativeWind changed more than the code behind Troco.
- TanStack Form
- Zod
- Zustand
- Tailwind CSS
- NativeWind
Troco looks simple from the outside. Enter a purchase amount, enter what the customer paid, and get the change. The arithmetic is not the hard part. The hard part is making that answer fast, clear and dependable for someone who may repeat the action hundreds of times during a shift.
That pressure shaped the new version of Troco. It also gave us a useful place to reconsider the frontend stack we use at Trebla. We chose TanStack Form, Zod, Zustand, Tailwind CSS and NativeWind. Each solved a different class of problem. More importantly, each forced us to be clearer about where a problem belongs.
This is not a list of libraries we like. It is a record of what changed after we gave those libraries narrow jobs and stopped asking any one of them to become the architecture.
A stack is a set of boundaries
Earlier Troco iterations used Formik, then React Hook Form. Both were reasonable choices for the application we had at the time. The new version changed the question. Forms were no longer isolated pieces of UI. The calculator, currency selection, validation, accessibility feedback and future flows all needed to share a more deliberate model.
We selected TanStack Form for that new form architecture. It is headless and designed around typed field state, which matters to us because Troco has its own components and interaction rules. We do not want a form library deciding how an input looks. We want it to make field state, submission state and validation timing explicit while our design system owns the interface.
The useful part is not the novelty. It is the boundary. Form state belongs to the form. It does not leak into a global store, and it does not hide inside a screen component as a growing collection of local state variables. A field has a value, metadata and validation lifecycle. The form has submission rules. The component renders those facts.
That separation makes reviews less speculative. Instead of asking whether a disabled button can get out of sync with three pieces of state, we can follow the form model. Instead of inventing a custom convention for every new flow, we can use the same vocabulary across the codebase.
Forms belong to the domain
A form model still needs to know what valid data means. That is where Zod enters the picture.
Troco represents money as integer minor units. A purchase of 18.50 is stored and calculated as 1850, not as a floating point number that may lose precision. Supported currency codes are a finite set. Amounts have practical limits. A purchase must be greater than zero, and raw input needs to become a valid calculation before it reaches the domain function.
Those rules live in schemas close to the domain package. The form can use them, but it does not own them. A screen asking for an amount and a function calculating change should not develop separate definitions of valid money.
Zod gives us runtime validation and TypeScript inference from the same schema. That combination removes a quiet source of drift. A type alone disappears at runtime. A hand-written validator alone can disagree with the type. A shared schema gives us one executable statement of the boundary.
There is a trap here. A schema folder can become a junk drawer where every business decision is expressed as a refinement. We keep arithmetic and actual behavior in plain domain functions. Schemas answer whether data is shaped and constrained correctly. They do not replace the model.
This distinction has changed how we discuss features. A review comment can point to a contract, not a vague expectation. A test can parse the same input the application receives. Someone joining the project can find the supported currencies and money limits without reconstructing them from component code.
Small state should stay small
Troco needs state outside a single screen. The chosen currency, larger text preference, history behavior and a few interface settings should survive navigation and, where appropriate, application restarts. That does not mean the whole application needs to live in one store.
We use Zustand for this narrow client state. The preferences store exposes selectors and explicit actions. Persistence sits behind a repository. The store hydrates from storage and keeps the current session usable even if a storage write fails.
The small size is intentional. Calculation results, form fields and component details do not enter the preference store just because it is convenient to import. TanStack Form owns form state. Domain functions own calculation. React owns local presentation state. Zustand owns the small set of choices that genuinely need a longer lifetime.
This has made state work easier to review. A new field in the store is a visible architectural decision. The question is not merely whether it works. The question is whether it deserves global lifetime. That tiny amount of friction is useful.
It also makes tests more honest. We can construct the vanilla store with a storage repository, change a preference, simulate a failure and observe the result without rendering the application. The React hook is an adapter over behavior that already has a clear shape.
One visual language, two rendering environments
Trebla builds for the web and for native mobile platforms. We wanted both environments to use the same visual vocabulary without pretending that a browser and a React Native view hierarchy are the same thing.
Tailwind CSS gives the web project a constrained set of utilities. NativeWind brings a related class-based workflow to React Native. The obvious benefit is speed, but the more durable benefit is naming. Spacing, color, type scale, surfaces and states can be discussed with the same terms across projects.
In Troco, tokens such as paper, ink, surface, border and primary are not isolated hex values copied between files. They are part of a system. A designer can point to a surface role. An engineer can find that role in the theme. A reviewer can see when a component reaches for an arbitrary value instead of using the established language.
Shared vocabulary does not mean shared markup. NativeWind is not a browser renderer, and a native pressable should not be treated like an anchor element. Safe areas, keyboard behavior, touch targets, platform typography and accessibility APIs remain platform work. The classes reduce translation cost. They do not erase the platform.
Utility-heavy code also has a readability cost. A long string of classes can hide the component's intent. We respond by extracting components around behavior and repeated visual roles, not by naming every collection of utilities. If a pattern has a stable product meaning, it becomes a component. If it appears once, it can stay local.
What changed inside the company
The largest impact is not that we write fewer lines. Line count is a poor proxy for software quality. The change is that more decisions now have an obvious home.
Form behavior is discussed through TanStack Form and domain schemas. Runtime contracts are discussed through Zod. Persistent client preferences are discussed through Zustand. Visual decisions are discussed through shared tokens expressed with Tailwind and NativeWind. None of these tools eliminates judgment, but each gives judgment a smaller surface.
That affects code review. Reviewers spend less time decoding custom glue and more time checking the decision itself. Is this rule really part of the domain? Should this state outlive the screen? Does this visual value belong in the system? The questions become sharper because the boundaries are visible.
It affects onboarding too. A new engineer still needs to learn the product, but they do not need to learn five private mini-frameworks before changing a form. Established libraries provide documentation, familiar concepts and a route into the code. Our job is to document the Trebla-specific decisions layered on top.
There is also a company-level benefit in moving between projects. Troco is not the only software we build. A shared approach to schemas, small stores and utility-based styling lets an engineer carry useful instincts from one codebase to another. The components remain product-specific. The questions stay familiar.
The tradeoff is maintenance discipline. Dependencies move. Major versions change assumptions. Type inference can become slow if schemas grow carelessly. A store can expand until it owns everything. Utilities can become visual noise. Choosing a library creates an obligation to understand its release path and keep its responsibility narrow.
Modern is a moving target
We do not expect this exact list of tools to be permanent. Earlier Troco versions made different choices, and some of those choices were right for their moment. The point of the new stack is not to freeze the project around what is fashionable in 2026.
The point is to make change less expensive. Contracts can move without rewriting every consumer. Form state can evolve without becoming global state. Visual tokens can change without searching for scattered color values. Platform code can remain platform code while sharing a language with the web.
That is what modern tooling changed for Trebla. It gave us better boundaries, then made us defend them. The libraries helped. The lasting improvement came from deciding what each library is allowed to own.