DEV Community

Sergey
Sergey

Posted on

You Don't Modularise a Frontend by Moving Files

You Don't Modularise a Frontend by Moving Files

Notes from trying to give one React component a place it could actually call its own

We wanted to split a large frontend into packages.

This sounded, at first, like a file-moving problem.

We had a component. Let's call it WeatherCard.

It showed the current weather, allowed the user to save a location, sent analytics events, behaved slightly differently on the home page and in search results, participated in an experiment, and knew whether the application was running on mobile.

Nothing particularly exotic.

It lived here:

src/
  feed/
    cards/
      weather/
        WeatherCard.tsx
Enter fullscreen mode Exit fullscreen mode

We wanted it here:

packages/
  weather-card/
Enter fullscreen mode Exit fullscreen mode

So we moved it.

That took an afternoon.

Modularising it took considerably longer.

The files moved. The application came with them.

The first package looked promising.

packages/
  weather-card/
    WeatherCard.tsx
    WeatherIcon.tsx
    weather.css
    index.ts
Enter fullscreen mode Exit fullscreen mode

It had a package.json.

It had tests.

It even had a pleasantly small public export.

export { WeatherCard } from './WeatherCard';
Enter fullscreen mode Exit fullscreen mode

Then I looked at the imports.

import { useSelector } from 'react-redux';
import { useTranslation } from '../../i18n';
import { useExperiment } from '../../experiments';
import { useAnalytics } from '../../analytics';
import { useRouter } from '../../router';
import { selectSavedLocations } from '../../store';
Enter fullscreen mode Exit fullscreen mode

The package was physically separate and architecturally attached to almost everything around it.

An import is at least honest about this. Package managers can see it. Build tools can see it. Static analysis can complain about it.

Other dependencies are more discreet.

Our component appeared to have a wonderfully small API:

<WeatherCard item={item} />
Enter fullscreen mode Exit fullscreen mode

That was not really its API.

Its inputs looked more like this:

WeatherCard
    │
    ├── item
    ├── Redux store
    ├── router
    ├── translations
    ├── experiments
    ├── analytics
    ├── application type
    └── device configuration
Enter fullscreen mode Exit fullscreen mode

Only one happened to be written in the function signature.

The others arrived through the atmosphere.

This wasn't an argument against Redux or React Context. Both had been useful. The problem appeared only when we tried to establish a boundary.

Global context doesn't remove dependencies.

It makes some of them easier not to see.

An adapter helped

We introduced an adapter.

function WeatherCardAdapter({ rawItem }: Props) {
  const savedLocations = useSelector(selectSavedLocations);
  const t = useTranslation();
  const router = useRouter();
  const experiment = useExperiment('compact-weather-card');
  const analytics = useAnalytics();

  return (
    <WeatherCard
      rawItem={rawItem}
      savedLocations={savedLocations}
      translate={t}
      compact={experiment.enabled}
      onOpenLocation={router.open}
      onEvent={analytics.send}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

WeatherCard no longer needed to know where these things came from.

The application-facing dependencies accumulated in the adapter instead.

Application
     │
     ▼
WeatherCardAdapter
     │
     ▼
 WeatherCard
Enter fullscreen mode Exit fullscreen mode

There was an immediate problem.

The component interface became ugly.

We fixed the ugly interface

The real component looked closer to this:

<WeatherCard
  rawItem={rawItem}
  savedLocations={savedLocations}
  translate={translate}
  compact={compact}
  onOpenLocation={onOpenLocation}
  onSaveLocation={onSaveLocation}
  onEvent={onEvent}
  isMobile={isMobile}
  pageType={pageType}
/>
Enter fullscreen mode Exit fullscreen mode

Nine props.

We had taken a component with a neat API and turned it into this.

So we did what seemed sensible.

We introduced WeatherCardContext.

interface WeatherCardContext {
  savedLocations: SavedLocation[];
  translate: Translate;
  compact: boolean;
  openLocation: (id: string) => void;
  saveLocation: (id: string) => void;
  sendEvent: (event: WeatherEvent) => void;
  isMobile: boolean;
  pageType: PageType;
}
Enter fullscreen mode Exit fullscreen mode

The component became tidy again.

<WeatherCard
  rawItem={rawItem}
  context={weatherCardContext}
/>
Enter fullscreen mode Exit fullscreen mode

Much better.

For perhaps a week, I thought we had found the right abstraction.

Then another card needed the same translation function.

And the same navigation behaviour.

But not the same analytics.

A third card needed analytics and device information, but had a different interpretation of pageType.

We started constructing context objects.

Then helpers for constructing context objects.

Then a common context from which card-specific contexts could be constructed.

The dependency graph we had been trying to remove was slowly reappearing inside one object.

Worse, code review had become harder.

This:

<WeatherCard context={context} />
Enter fullscreen mode Exit fullscreen mode

said almost nothing about what changing WeatherCard might affect.

We had successfully shortened the interface.

We had also hidden it again.

So we deleted WeatherCardContext and put the ugly props back.

That felt like going backwards.

It wasn't.

The long interface was information.

Some props belonged to the weather card:

rawItem
savedLocations
Enter fullscreen mode Exit fullscreen mode

Some were capabilities supplied by the application:

onOpenLocation
onSaveLocation
onEvent
Enter fullscreen mode Exit fullscreen mode

Some described presentation:

compact
Enter fullscreen mode Exit fullscreen mode

And some looked suspicious:

isMobile
pageType
Enter fullscreen mode Exit fullscreen mode

Why did a weather card need to know that it was on mobile?

The answer was in the rendering code.

if (isMobile) {
  return <CompactTemperature temperature={temperature} />;
}

return <TemperatureWithDetails temperature={temperature} />;
Enter fullscreen mode Exit fullscreen mode

It didn't care about mobile.

It cared about presentation.

So:

isMobile={isMobile}
Enter fullscreen mode Exit fullscreen mode

became:

displayMode="compact"
Enter fullscreen mode Exit fullscreen mode

A tiny change, but a useful one.

isMobile described the world outside the card.

displayMode described the card.

The ugly interface had shown us where to look.

Sometimes an ugly interface is an X-ray.

Then we found the state

Saving a location changed global Redux state.

That seemed reasonable. The application needed to know which locations were saved.

The card also stored whether its save animation was running in Redux.

That seemed less reasonable.

Then we found whether the tooltip had been dismissed.

Also Redux.

Whether the expanded forecast was open.

Redux.

Whether the user was currently hovering over the card.

Thankfully, not Redux.

We had apparently drawn a boundary somewhere. We just couldn't explain why it was there.

Asking whether something belonged in Redux wasn't getting us very far, so we changed the question.

Who owns this state?

The expanded forecast existed only while the card existed.

The save animation existed because the card was displaying it.

The tooltip belonged to that interaction.

They moved inside.

function useWeatherCard() {
  const [expanded, setExpanded] = useState(false);
  const [saving, setSaving] = useState(false);
  const [tooltipVisible, setTooltipVisible] = useState(true);

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Saved locations were different.

Another card could save the same location. Navigation could show the number of saved locations. The information survived the lifetime of any individual WeatherCard.

That stayed outside.

This gave us a rule we could actually use:

If state needs to be observed outside a boundary, either the observer belongs inside the boundary or the state doesn't.

It was a more useful question than choosing between Redux and useState.

The application knew what weather looked like

Data was next.

The API returned something like:

{
  "type": "weather",
  "id": "london",
  "temperature": 17,
  "units": "celsius",
  "forecast": [],
  "alerts": [],
  "provider": {}
}
Enter fullscreen mode Exit fullscreen mode

The application parsed this response.

The feed extracted fields.

The card received the resulting object.

This had always worked.

It also meant that changing the server representation of a weather card could require changes in code that otherwise had nothing to do with weather.

We tried making the data opaque to the application.

The feed needed surprisingly little:

interface FeedItem {
  id: string;
  type: string;
  rawItem: unknown;
}
Enter fullscreen mode Exit fullscreen mode

The rest could cross the boundary untouched.

Server
   │
   ▼
Feed
   │
   │ rawItem
   ▼
WeatherCard
   │
   ▼
Weather parser
   │
   ▼
Weather model
Enter fullscreen mode Exit fullscreen mode

The parser moved into the package.

const WeatherItem = object({
  id: string,
  temperature: number,
  units: oneOf('celsius', 'fahrenheit'),
  forecast: array(Forecast),
});
Enter fullscreen mode Exit fullscreen mode

This was one of the quieter changes in the migration.

It removed no visible feature. It probably made no benchmark move.

What changed was who had to understand the data.

The feed stopped understanding weather.

That was useful.

A string that wasn't a string

Packages gave us another tool: compilation boundaries.

Suppose some application code accepted a location identifier.

type LocationId = string;

function openWeather(locationId: LocationId) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Later the contract changed. openWeather could no longer accept any location identifier. It required the canonical location selected after resolving aliases.

Unfortunately:

LocationId = string
CanonicalLocationId = string
Enter fullscreen mode Exit fullscreen mode

From TypeScript's point of view, nothing had happened.

The compiler was correct.

It was proving the wrong thing.

We wanted consumers to acknowledge the semantic change, so we gave the new identifier a distinct type.

type CanonicalLocationId = string & {
  readonly __brand: 'CanonicalLocationId';
};
Enter fullscreen mode Exit fullscreen mode

Suddenly the build failed in several places.

Good.

Each error represented a consumer whose assumption needed to be reconsidered.

The branded type wasn't particularly sophisticated. What interested me was what the failure meant.

We had turned part of an architectural migration into something the compiler could inspect.

A boundary described in documentation is an intention. A boundary that can fail the build has teeth.

One experiment, three teams

The compact layout had started as a single experiment.

compact-feed-cards
      │
      ├── WeatherCard
      ├── NewsCard
      └── SportsCard
Enter fullscreen mode Exit fullscreen mode

This had seemed like sensible reuse.

The three cards were testing roughly the same visual idea, so one flag was simpler than three.

Then the weather implementation was ready.

The news implementation was nearly ready.

Sports had discovered an interaction problem and needed another week.

Nothing was broken. There was no technical incident. We simply couldn't finish the weather experiment without deciding what to do about sports.

The options were not attractive.

We could wait.

We could add special-case logic around the shared experiment.

We could coordinate a partial rollout between teams.

Or we could admit that the experiment wasn't actually one thing.

We split it:

compact-weather-card
compact-news-card
compact-sports-card
Enter fullscreen mode Exit fullscreen mode

Three flags where there had been one.

Less DRY.

The weather team shipped.

A few days later news shipped.

Sports changed its implementation and shipped after that.

The interesting dependency had never appeared in the module graph. There was no import from weather to sports. There was no shared component.

They shared a release decision.

That was enough to couple them.

After that I became less interested in counting dependencies and more interested in looking for things that had to change together.

Sometimes that was an import.

Sometimes state.

Sometimes a server format.

Sometimes an experiment.

Sometimes a release process.

They looked different in code.

Operationally, they behaved rather similarly.

The architecture got worse

After the first few migrations, our architecture diagram looked worse than when we started.

Imagine a feed with sixty card types.

We had migrated four.

It now looked roughly like this:

Feed
 ├── LegacyNewsCard ───── Redux ── Context ── ...
 ├── LegacySportsCard ─── Redux ── Context ── ...
 ├── LegacyFinanceCard ── Redux ── Context ── ...
 │
 ├── WeatherCardAdapter
 │       │
 │       ▼
 │   WeatherCard
 │
 ├── TravelCardAdapter
 │       │
 │       ▼
 │   TravelCard
 │
 └── ...54 more
Enter fullscreen mode Exit fullscreen mode

Before the migration there had been one architecture.

Now there were two.

We had more adapters, more package boundaries and more concepts to explain to somebody joining the project.

Meanwhile product development continued.

A legacy card would gain a feature while we were extracting another card. Occasionally somebody would add a new global dependency to a component we intended to migrate next.

For a while this felt like evidence that the migration was too slow.

The tempting response was to accelerate it.

Take ten cards.

Stop touching them for product work.

Move them together.

Clean up the intermediate abstractions afterwards.

On paper this was much more efficient.

It also meant that instead of learning whether the boundary worked from one card, we would learn after ten.

So we kept the migration deliberately uneven.

Four cards became six.

Six became nine.

The remaining cards stayed exactly as awkward as they had been before.

This had an unexpected benefit.

The new architecture had to earn its keep while surrounded by the old one.

If extracting WeatherCard required changing all sixty cards, the design was not useful yet.

If the new package required every feed to adopt a new state model simultaneously, the design was not useful yet.

If a parser could move only after the entire API response was redesigned, it was not useful yet.

The constraints of incremental migration rejected quite a few elegant ideas for us.

I am grateful for that now.

An architecture that works only after the migration is finished has a bootstrapping problem.

For a large, continuously changing system, the intermediate states are not a brief inconvenience between two architectures.

They are where you live.

Our migration would take months.

For most of that time, the transitional architecture was the architecture.

That changed the question we asked about new abstractions.

Not:

Does this get us to the cleanest final design?

More often:

Does this make the next card cheaper to change without making the remaining fifty-one harder to live with?

That produced smaller steps.

It also meant we could stop.

Had priorities changed after twelve cards, we would still have had twelve cards with clearer ownership and forty-eight functioning legacy cards.

There would have been no half-built platform waiting for the rest of the application to catch up.

The destination still mattered.

The ability to stop on the way mattered more than I had expected.

The card became boring

Eventually WeatherCard became surprisingly uninteresting.

It received data.

It parsed the data it owned.

It managed its local interactions.

It asked the application to perform things outside its boundary.

<WeatherCard
  rawItem={rawItem}
  displayMode="compact"
  savedLocations={savedLocations}
  onSaveLocation={saveLocation}
  onOpenLocation={openLocation}
  onEvent={sendWeatherEvent}
/>
Enter fullscreen mode Exit fullscreen mode

It didn't know which state library the application used.

It didn't know which router it used.

It didn't know where experiments came from.

It didn't know whether it was rendered in the main application, Storybook or a test harness.

The adapter knew considerably more.

                   Application
                        │
       ┌────────────────┼────────────────┐
       │                │                │
     Router          State          Experiments
       │                │                │
       └────────────────┼────────────────┘
                        ▼
                WeatherCardAdapter
                        │
                        ▼
                   WeatherCard
Enter fullscreen mode Exit fullscreen mode

We had not eliminated the complexity.

We had changed where knowledge was allowed to accumulate.

I think that distinction matters.

The files were the easy part

Looking back, moving WeatherCard into packages/weather-card was one of the least interesting things we did.

The package became useful only as other changes accumulated around it.

Dependencies that had been ambient became explicit.

State acquired owners.

Data was interpreted closer to the code that understood it.

Some semantic contracts became compilation failures.

Release decisions that looked shared turned out not to be.

And the old application remained operational while all of this happened.

I started with a fairly mechanical picture of modularity:

one large thing
      ↓
several smaller things
Enter fullscreen mode Exit fullscreen mode

That picture isn't wrong.

It just doesn't say very much.

The question I find more useful now is:

If this part of the system changes, how much of the rest of the system needs to know?

For WeatherCard, the answer gradually became: less.

Not nothing.

Just less.

That turned out to be enough.

Top comments (0)