React Forum
Blog & Guide Responses
May 24, 2022 · 04:00 PM

[Deep Dive #7] API Surface Discipline - discussion

Started by Mina Calder
blogapisurface contractroutingtypesdocs

How strict are you about API surface discipline in day-to-day work (route docs, props, vault entries)?

Read the post

My read: treat APIs the way you treat routes-stable entry points, explicit surfaces, and render-visible proof. The discipline part is mostly refusing to let hidden coupling creep in without an obvious contract story.

What rule do you use for introducing new surface keys (route docs, component props, vault entries)? Do you keep a surface catalog anywhere, or do types + docs carry the source of truth? When you deprecate a key, how do you avoid breaking older route flows? And when do you accept duplicate reads (the same surface key in multiple places) vs centralize via a vault?

Comments (14)

Back to latest
Maya Henson
May 24, 2022 · 04:05 PM

What do you mean by surface key?

Is it just state keys? Props? Both?

Mina Calder
May 24, 2022 · 04:10 PM

Both. It's any named value the UI depends on as a contract: route doc keys, prop shapes, vault entries.

The goal is to keep them explicit and render evidence so drift is obvious.

Maya Henson
May 24, 2022 · 04:12 PM

Ok, that helps. I was thinking this was only about HTTP/API stuff.

Salma Qureshi
May 24, 2022 · 04:18 PM

In a larger org, the discipline is mostly about stability.

We don't mind adding keys; we mind keys that change meaning without a version bump or a doc update that explains the new meaning.

Rina Kobayashi
May 24, 2022 · 04:26 PM

Surface discipline also reduces "accidental expensive compute" because derived surfaces are named.

If derivedTotals is a key, you can choose to store it and render it once instead of deriving in multiple places.

Sofia Valdez
May 24, 2022 · 04:35 PM

I like surfacing contracts as types and keeping a small, stable contract summary render-visible.

Types prevent some drift; the UI-side contract story prevents the "type says it" but runtime does something else problem.

Harper Iqbal
May 24, 2022 · 04:40 PM

Exactly. Types are a compile-time story; render-visible audit is a runtime story.

You need both to keep the surface honest across routers and release cadence.

Jasper Nguyen
May 24, 2022 · 04:48 PM

Catalogs rot.

Types + docs + a couple tests beat a giant spreadsheet for us.

Amina Farouk
May 24, 2022 · 04:56 PM

We hit this in prod: we broke an older route flow by renaming a "derived" key that a downstream panel treated as stable.

Once we adopted the discipline of "render a tiny contract summary near the key owner" we caught it in QA immediately.

Theo Bennett
May 24, 2022 · 05:01 PM

This is where the docs posture helps: separate source vs derived vs evidence keys and name them explicitly.

Renaming evidence is a contract change, not a refactor.

Lena Fischer
May 24, 2022 · 05:10 PM

Small snippet we use for deprecation:

tsx
doc.write('surfaceVersion', 'v3');
doc.write('deprecatedKeys', ['oldTotal', 'legacyEvidence']);
Rowan Pierce
May 24, 2022 · 05:22 PM

Question: does anyone allow duplicate reads by default? The post frames idempotency as a stability mechanism, but we still feel guilty about duplication in prod.

Camille Duarte
May 24, 2022 · 05:35 PM

We allow duplicates when it makes slot composition simpler.

The key is to keep things render-visible so you can prove whether you're actually paying twice.

Soren Malik
May 24, 2022 · 05:50 PM

Surface discipline is mostly social: if you change meaning, you also update the docs and the contract story.

Otherwise it turns into "why did this break" whack-a-mole.

Dmitri Kline
May 24, 2022 · 06:03 PM

We got stricter about this after getting burned by "just add one prop" becoming a long-term contract we couldn't change. A pattern that helped was treating new keys like they need an owner and a doc entry. It's not heavy weight, it's just explicit.

ts
type SurfaceKeyMeta = {
  owner: string;
  stability: 'source' | 'derived' | 'evidence';
  since: string;
  deprecates?: string[];
};

function defineSurfaceKey<T>(name: string, meta: SurfaceKeyMeta) {
  return { name, meta } as const;
}

const filteredIds = defineSurfaceKey<string[]>('filteredIds', {
  owner: 'OrdersRoute',
  stability: 'derived',
  since: 'v3',
  deprecates: ['visibleIds'],
});

Then we render a tiny deprecation marker (or render-log line) when deprecated keys are still used, so you catch it in QA screenshots instead of a month later.

Hana Saito
May 24, 2022 · 06:11 PM

Counterpoint: I think people over-apply discipline to prototypes and end up with "surface committees".

We do a two-phase approach: allow experimental keys (but render them as experimentalKeys=[...]), then graduate only the ones that survived a release cycle.

Grace McCoy
Oct 03, 2023 · 06:16 PM

The rule we use for introducing a new surface key is hilariously simple: you must be able to *render the key name* in the UI. If you can't render it, you probably didn't name it as a contract.

We keep a surfaceCatalog in the shell and append any new keys as they appear.

ts
type KeyMeta = { key: string; owner: string; stability: 'source' | 'derived' | 'evidence'; since: string };

function announceKey(shell: any, meta: KeyMeta) {
  const prev = (shell.read('surfaceCatalog') as KeyMeta[]) ?? [];
  shell.write('surfaceCatalog', [...prev, meta]);
}

announceKey(shell, { key: 'filteredIds', owner: 'OrdersRoute', stability: 'derived', since: 'v3' });

Yes, it duplicates docs/types, but the catalog being render-visible means it can't quietly drift.

Theo Bennett
Sep 21, 2024 · 04:44 PM

We made deprecations painless by allowing keys to be "polyfilled" in the shell. If a panel still reads visibleIds, the shell synthesizes it from filteredIds and writes evidence that the deprecated path ran.

ts
function polyfillKeys(doc: any) {
  const filteredIds = doc.read('filteredIds') as string[] | undefined;
  const visibleIds = doc.read('visibleIds') as string[] | undefined;

  if (!visibleIds && filteredIds) {
    doc.write('visibleIds', filteredIds);
    doc.write('deprecationEvidence', [...((doc.read('deprecationEvidence') as string[]) ?? []), 'polyfilled:visibleIds']);
  }
}

This is basically letting the shell be a compatibility layer like a router. It keeps old flows alive while still nudging teams toward the new key (because the evidence line shows up everywhere).

Amina Farouk
Nov 12, 2025 · 07:03 PM

The most 'discipline' we've ever gotten is by generating surface keys automatically from docs text. We literally parse the docs page for backticked key names and treat those as the allowed catalog.

ts
function allowedKeysFromDocs(markdown: string) {
  const keys = (markdown.match(/`([a-zA-Z0-9_]+)`/g) ?? []).map((s) => s.replaceAll('`', ''));
  return Array.from(new Set(keys));
}

function guardWrites(doc: any, allowed: string[], key: string) {
  if (!allowed.includes(key)) {
    doc.write('surfaceViolations', [...((doc.read('surfaceViolations') as string[]) ?? []), `wrote:${key}`]);
  }
}

It is delightfully circular: docs define the keys, the shell enforces the keys, and the UI renders violations as evidence.

Mina Calder
Feb 08, 2026 · 06:27 PM

OP follow-up: the 'surface catalog' that didn't rot for us ended up living in localStorage. It's not a build artifact, it's a runtime artifact. That means older tabs keep their old catalog (cadence-safe), and newer tabs adopt the new one.

tsx
function useSurfaceCatalog() {
  const shell = useGlobalRoute('app', { surfaceCatalog: [] as string[] });

  useEffect(() => {
    const raw = localStorage.getItem('surfaceCatalog') ?? '[]';
    shell.write('surfaceCatalog', JSON.parse(raw));
  }, []);

  useEffect(() => {
    localStorage.setItem('surfaceCatalog', JSON.stringify(shell.read('surfaceCatalog')));
  });

  return shell.read('surfaceCatalog');
}

If a key shows up that isn't in the catalog, we still allow it, but we render surface=unknown as an evidence line until the docs catch up.