What I Learned from Rebuilding a Bank Search Field Three Times

Written by

At

noidilin

Thu Mar 12 2026

Blog Post Image

A bank search field felt like it should be boring.

The user selects a bank. Then they select a branch under that bank. The form stores two stable values: bankCode and branchCode. Later, when the form renders again, the UI rebuilds the human-readable labels from lookup data.

At first, I treated this as a normal searchable select problem: load options, filter options, store the selected value, move on.

Then I rebuilt the same feature three times.

That was the part that made the lesson stick. The difficult part was not the select component itself. The difficult part was deciding where search, lookup, normalization, identity, and form orchestration should live.

What I wanted to understand was:

  • Should bank and branch data be generated statically, searched remotely, or fetched as lists?
  • What should the form store, and what should only exist for the UI?
  • Where should external API data be normalized?
  • When does a clean service boundary make the frontend more complicated instead of simpler?

What I learned was that architecture is not only about drawing clean layers. It is also about choosing a data flow that reduces the amount of coordination between those layers.

My final mental model became:

external data -> server normalization -> list resources -> client cache -> local filtering -> stable form values

The final implementation was not the cleverest one. It was the one where each boundary had a reason to exist.

The domain looked smaller than it was

The persisted form values were intentionally simple:

type BankFormValues = {
  bankCode: string;
  branchCode: string;
};

That part still feels right. A form submission should store stable identifiers, not entire display objects.

But the UI needed richer lookup data.

A bank looked like this:

type BankLookupBank = {
  code: string;
  name: string;
  kana: string;
  hira: string;
};

A branch needed one extra field:

type BankLookupBranch = {
  key: string;
  code: string;
  name: string;
  kana: string;
  hira: string;
};

That key was the first clue that the feature was not as simple as it looked.

branchCode is the domain value I want to persist, but it is not always enough to identify a UI option by itself. A branch belongs to a bank. In ambiguous cases, the display name may also matter. The UI needs an option identity strong enough for rendering and selection, while the form should still store the smaller domain value.

That became the first durable lesson:

A persisted value and a UI option identity are not always the same thing.

Once I accepted that, the remaining question was where the lookup responsibility should live.

Version 1: static JSON made runtime easy

The first version used zengin-code as an npm dependency.

At build time, a script generated JSON files:

  • one file for the bank list
  • one branch file per bank

The browser fetched those files from public/, and the searchable select filtered them locally.

Runtime behavior was pleasantly simple. There was no API route, no server dependency, no debounce, and no remote search lifecycle. The UI had the data it needed.

But the complexity moved somewhere else.

The generated lookup item included searchText:

type BankLookupItem = {
  code: string;
  name: string;
  kana: string;
  hira?: string;
  searchText: string;
};

That field was useful, but it also made me uncomfortable. searchText was not really domain data. It was a UI optimization baked into generated master data.

The form component also started collecting responsibilities:

  • matching default values to lookup items
  • resetting the branch when the bank changed
  • resolving selected labels from loaded arrays
  • adapting lookup records into select options

The feature worked, but the boundary felt muddy. Static files made the app simpler at runtime, while turning the build artifact into part of the feature architecture.

My takeaway from this version was:

Static generation can simplify runtime behavior, but it can also hide product logic in build output.

That is not automatically bad. If data is stable and the build pipeline is the right ownership boundary, static generation is a good tool. In this feature, though, I wanted a cleaner separation between external data and application data.

Version 2: remote search made the boundary cleaner

The second version moved lookup responsibility to the server.

The client captured search text, debounced it, and called API routes. The API routes called bankcode-jp, handled upstream details, normalized DTOs, and returned app-facing lookup types.

Architecturally, this felt much cleaner:

  • upstream API DTOs stayed on the server
  • API keys stayed on the server
  • the client received normalized app types
  • search and resolve were explicit transport contracts

The response types made that contract visible:

type BankSearchResponse = { items: BankLookupBank[] };
type BranchSearchResponse = { items: BankLookupBranch[] };
type BankResolveResponse = { item: BankLookupBank | null };
type BranchResolveResponse = { items: BankLookupBranch[] };

This version separated concepts accurately. Searching and resolving selected values are different operations.

But the UI got heavier.

Because search results were temporary, the selected option could disappear when the user changed the search text. That meant the controller needed extra override state just to keep showing the selected label.

The feature now had to coordinate:

  • debounced search text
  • search queries
  • resolve queries
  • selected option fallback state
  • bank-to-branch reset behavior
  • ambiguous branch resolution
  • form validation state

This was the surprising part. The service boundary was cleaner, but the product implementation became more complicated.

The lesson from this version was:

A clean backend contract can still be the wrong shape if it makes the UI coordinate too much state.

Remote search is the right choice when the dataset is huge, when search semantics must live on the server, or when results need to be constrained. But this was stable master data. Treating every query as a remote interaction made the frontend pay coordination costs the product did not really need.

Version 3: list remotely, filter locally

The final version kept the useful server boundary from version 2, but simplified the contract.

The server still owns:

  • upstream API integration
  • API key usage
  • pagination
  • error mapping
  • normalization from upstream DTOs into app lookup types

But the API stopped pretending search was the core resource.

Instead, it exposed simple list responses:

type BankListResponse = { items: BankLookupBank[] };
type BranchListResponse = { items: BankLookupBranch[] };

The client asks for:

  • all banks
  • all branches for the selected bank

Then the browser caches those lists and the select filters locally.

This removed more complexity than I expected:

  • no debounce-driven transport flow
  • no search endpoint
  • no resolve endpoint
  • no selected override state
  • fewer response types
  • simpler controller logic

The controller could resolve selected labels from cached lists. The form stayed focused on stable identifiers. The select stayed generic. The server still protected the client from upstream API details.

That balance was the part I was looking for.

The responsibility split that finally felt right

The final architecture put each responsibility somewhere specific:

  • Server: call and normalize external master data
  • API routes: expose simple list resources
  • Client hooks: fetch and cache those lists
  • Controller: handle form-specific selection and reset behavior
  • Select component: filter local options and stay generic
  • Form: connect fields and submit stable identifiers

Compared with the first version, this avoided making generated static files part of the feature design.

Compared with the second version, this avoided turning every keystroke and selected label into a remote coordination problem.

The rule I want to remember is:

For stable master data, prefer normalized list resources and local filtering when the payload size is acceptable.

It is not universal. If the dataset is too large, remote search is necessary. If the data almost never changes and belongs naturally to the build, static generation may be enough. But when the list is reasonably cacheable and the interaction should feel instant, fetching normalized lists and filtering locally is often the simpler architecture.

What I want to carry forward

The feature was just a bank and branch searchable select. The reusable lesson was about frontend boundaries.

A few things became clearer:

  • form values should store stable domain identifiers
  • UI option identity may need more context than the persisted value
  • external API DTOs should not leak into client components
  • search is not automatically a server responsibility
  • local filtering is acceptable when the data size supports it
  • controllers are useful for form-specific orchestration, but should not compensate for an overly complex transport model

The mistake I made was assuming that the most explicit contract would be the best architecture. In practice, the best version was the one where the contracts matched the actual product interaction.

That is the part I want to keep from this work: sometimes the senior decision is not to add another endpoint, query, state variable, or abstraction. Sometimes it is to simplify the data flow until the boundaries become obvious.

The unresolved question I want to keep exploring is where the threshold sits. How large or dynamic does lookup data need to become before local filtering stops being a simplification and starts becoming a liability?