Frontend architecture
Shared React Components Need Migration Boundaries
What I learned moving form and table behavior across separate healthcare applications without turning a component library into an internal framework.
At LockeBio, the main product and the EMR lived in separate React monorepos. Both had patient, admin, and provider-facing applications. A third repository held packages shared between them.
The applications kept solving familiar frontend problems: forms, server-backed tables, API state, file uploads, drawers, and PDF viewing. Sharing some of that code made sense.
Sharing the workflows did not.
An order status and an encounter status may both appear in a table, but they belong to different products. The useful boundary was to package the mechanics and leave the healthcare rules inside the application that owned them.
Start with code that already works
The EMR had a local data-table implementation built around TanStack Table. It handled pagination, sorting, filters, rendering, and loading data from the API.
The same mechanics were needed elsewhere, so I moved that implementation into the shared package repository. I did not start by designing a new universal table API. The existing EMR screens were the specification.
After publishing the package, I migrated the EMR to use it and removed the local implementation.
Removing the old copy mattered as much as publishing the new one. If both versions remain available, they start drifting immediately. One receives a loading-state fix. The other gains a new filter. Soon the team is maintaining two components with nearly the same name and no clear owner.
The sequence was simple:
- Extract the working implementation.
- Move one real screen to the package.
- Remove the local copy.
That gave the package a production consumer from the beginning.
Keep product meaning at the call site
The shared data table owned the behavior that stayed the same across products:
- TanStack Table setup
- pagination and sorting state
- filter state
- server hydration
- loading and empty states
- query-parameter serialization
- shared column metadata
The applications still defined their own columns, permissions, routes, labels, and filters.
The package did not know which order needed attention or which encounter was overdue. It knew how to display rows and carry table state. The feature supplied the domain meaning.
That kept the call sites readable. An engineer working on an order screen could see the order rules there instead of tracing them into a shared package that was also used by the EMR.
The second use case changed the API
The table synchronized pagination, sorting, search, and filters to the URL. That was useful on full resource pages. An admin could refresh a filtered view or send the URL to someone else without losing the table state.
Later, I used the same table for an order queue embedded in a dashboard. The queue loaded six rows, but it did not own the page URL. Allowing it to synchronize parameters would have filled the dashboard URL with the state of an internal preview.
That real conflict produced one option: shouldSyncParams.
This is the LockeBio dashboard call site:
index.tsx
<DataTable
columns={columns}
EmptyState={<OrderAttentionEmptyState />}
footer={() => null}
shouldSyncParams={false}
{...hydratorProps}
/>Full resource tables kept the default URL behavior. The embedded queue opted out.
I prefer adding an option this way. Two working consumers disagreed about who owned the URL, so the package needed to express that difference. It was not flexibility added for a hypothetical future screen.
Small prop differences become migration work
The shared form package followed the same boundary. It handled React Hook Form integration and common Chakra controls. Each feature kept its schema, field names, validation rules, and submission flow.
During the migration, some form controls used name while others used different prop shapes. I standardized the shared controls on field and updated their consumers.
These are real call sites from the LockeBio applications:
index.tsx
<FormDropdown<Fields>
field="unit_system"
label="Unit System"
placeholder="Select unit"
collection={collection}
/>
<FormCheckbox field="is_bmi_required">
Request for BMI details
</FormCheckbox>This was not a large architectural decision. It was still important.
Package migrations are full of small translation costs. When every form control follows the same convention, the feature code is easier to scan and moving the next screen takes less work.
The field name also remains visible. The package handles registration and error behavior without hiding which value the feature owns.
Move one concern at a time
The package repository did not arrive as a single design-system rewrite. Common utilities, API state, PDF viewing, forms, and data tables moved as separate packages and releases.
That made each change easier to review. An application could update the data-table package without also changing every form and drawer.
It also forced each package to answer two questions: what repeated behavior does it own, and which application proves the API works?
Storybook came later, after the shared form controls had real consumers. That order worked well. Production screens shaped the API first. Storybook then gave the team a smaller place to exercise component states outside either healthcare application.
The boundary I use now
Before moving frontend code into a package, I check four things:
- The behavior already exists in a working screen.
- It can be described without naming one product workflow.
- A consumer can migrate without moving the entire application.
- The local implementation will be removed afterward.
If extracting the component makes the feature rules harder to find, the boundary is wrong.
A shared package should remove repeated mechanics. It should not become the place where every product decision goes because two screens happen to use React.