Translating React data arrays: stop trying to call t() in module scope

Module-scope data objects — const plans = [{ name: Free, ... }] — are the single hardest case in React i18n. Here's the pattern every major i18n library converged on, and how Polyglot automates the refactor.

6 min read← All articles

Every React codebase has this file. A const plans = [...] sitting at the top of a component module, each entry holding human copy: name: "Free", description: "For hobby projects", cta: "Get started". It's clean, it's declarative, and the moment you try to internationalize the app, it's a problem.

Why the naive fix doesn't work

The obvious move is to wrap each string with t(...):

const plans = [
  { name: t("plans.0.name"), description: t("plans.0.description") },
];

export default function Pricing() {
  return plans.map(...);
}

This crashes at import time with ReferenceError: t is not defined. t comes from a React hook — useTranslations() in next-intl, useIntl() in react-intl, a context in i18next. Hooks only exist inside a component render. Your const plans runs when the module is imported, long before any component mounts. There is no t in that scope and there never will be.

You could hoist the array into the component body to put t in scope — but that only works if the const is used by exactly one component in one file. Exports, shared data, and multi-component usage all break the move.

This isn't a library deficiency. It's a fundamental tension: the data is loaded at module time, the translation function only exists at render time, and they can't meet in the middle.

The pattern every i18n library converged on

Look at how the major React i18n libraries document this case and they all land in the same place: don't put strings in module-scope data. Put keys.

const plans = [
  { id: "free" },
  { id: "pro" },
];

export default function Pricing() {
  const t = useTranslations("plans");
  return plans.map((plan) => (
    <>
      <h2>{t(`${plan.id}.name`)}</h2>
      <p>{t(`${plan.id}.description`)}</p>
      <button>{t(`${plan.id}.cta`)}</button>
    </>
  ));
}

Or, slightly looser, the keys live in the data itself:

const plans = [
  { nameKey: "plans.0.name", descKey: "plans.0.description" },
];

Both variants preserve the same invariant: strings don't exist in module-scope data. They live in the translation catalog.

This isn't cosmetic. It's structural:

  • Locale switching works. Module-scope strings are baked at import time. If the user changes language, the imported string doesn't change. Catalog-first resolves at render.
  • Translators don't touch source. The i18n catalog is the single source of truth. Opening a JSON file is a translator's job; reading a .tsx file is not.
  • Static extraction tools cooperate. FormatJS's extract, Lingui's extract, i18next-parser — every extractor in the ecosystem assumes strings appear inline in JSX or via a recognized marker. Free-floating strings in object literals are the hard case, which is exactly why the ecosystem routed around them.
  • Type safety is real. You can generate types from your catalog; t() autocomplete gets you every valid key with zero drift.

It's not a tradeoff. The "keys in data, strings in catalog" pattern is strictly better for every production concern — easier locale switching, cleaner translator workflow, better tooling, better types.

What the refactor actually looks like

If you're doing it by hand, the refactor is two coordinated edits per entry:

  1. Replace the string value in the data with a stable key: name: "Free" → name: "plans.0.name".
  2. Wrap every JSX call site that reads that field with t(...): {plan.name} → {t(plan.name)}.

And then populate your catalog:

{
  "plans": {
    "0": { "name": "Free", "description": "For hobby projects" },
    "1": { "name": "Pro", "description": "For growing teams" }
  }
}

At runtime, plan.name evaluates to the string "plans.0.name", and t("plans.0.name") looks that up in the nested catalog. No scope issues. No missed strings. No re-engineering the data shape.

For a pricing page with four plans and eight fields each, that's 32 data edits + 32 call-site edits. Miss one and your UI ships an untranslated key name to the user. It's the kind of mechanical refactor that's exactly what you want a tool to do.

How Polyglot automates it

Polyglot's wrap command now performs this transform as a deterministic two-sided AST rewrite, when it can prove every place the data is read. Starting from src/app/pricing/page.tsx:

const plans = [
  { name: "Free", features: ["Up to 100 strings"], cta: "Get started" },
];

export default function Pricing() {
  return (
    <section>
      {plans.map((plan) => (
        <div>
          <h2>{plan.name}</h2>
          <ul>{plan.features.map((f) => <li>{f}</li>)}</ul>
          <button>{plan.cta}</button>
        </div>
      ))}
    </section>
  );
}

Running polyglot wrap (CLI 0.14.4) produces:

import { useTranslations } from "next-intl";

const plans = [
  { name: "src.app.pricing.page.plans.0.name", features: ["src.app.pricing.page.plans.0.features.0"], cta: "src.app.pricing.page.plans.0.cta" },
];

export default function Pricing() {
  const t = useTranslations();
  return (
    <section>
      {plans.map((plan) => (
        <div>
          <h2>{t(plan.name)}</h2>
          <ul>{plan.features.map((f) => <li>{t(f)}</li>)}</ul>
          <button>{t(plan.cta)}</button>
        </div>
      ))}
    </section>
  );
}

Keys are namespaced by file path, so two files with a plans array never collide. And messages/en.json:

{
  "src": {
    "app": {
      "pricing": {
        "page": {
          "plans": {
            "0": {
              "cta": "Get started",
              "features": {
                "0": "Up to 100 strings"
              },
              "name": "Free"
            }
          }
        }
      }
    }
  }
}

A few things happen under the hood:

  • Allowlist-driven. Only property keys on the translatable list (title, description, name, cta, features, …) are touched. CSS classes, href, id, type — all values the i18n ecosystem has long-agreed are not user copy — get left alone.
  • Shape-matched call sites. A JSX expression is wrapped only if its shape matches a key we rewrote. {plan.name} wraps because its shape plans.*.name is a leaf we rewrote. {plan.features} does not wrap because its shape plans.*.features points to a container, not a string.
  • Map binding analysis. .map((plan) => ...) tracks the plan binding's element shape. Nested maps like .map((f) => <li>{f}</li>) correctly identify f as a leaf and wrap it.
  • All consumers or nothing. If a field is also read somewhere that isn't a rendered translation call — key={plan.name}, console.log(plan.name), a comparison — rewriting the value would change that behavior too. So wrap leaves the whole array untouched and flags it in the report as unproven-consumer, with the exact location.
  • Destructured bindings are skipped with an explicit reason. .map(({ name }) => ...) would require renaming every name in scope to distinguish "the destructured field" from "any other variable named name" — we punt rather than guess.

Try it

If you've been blocked on i18n because of one exported array of strings somewhere in your codebase, this is the refactor that unblocks you. Run polyglot wrap --dry-run to see the plan, then polyglot wrap. Review the diff and the report, run your app, and commit. Anything wrap couldn't prove safe is listed in polyglot-i18n-report.md for you to finish by hand.

curl -fsSL https://getpolyglot.ai/install.sh | bash
cd your-app
polyglot scan              # See what you're missing
polyglot wrap --dry-run    # Preview the rewrite
polyglot wrap              # Rewrite to the catalog pattern
polyglot translate --languages de

Your first 50 strings translate free in one language, no account needed. The catalog's yours, the refactor is reversible with polyglot undo <run-id> (the ID is printed when wrap finishes), and your data arrays finally stop being the thing that blocks localization.

Start in your terminal

Stop hunting for untranslated strings.

Install the CLI, run a scan, and see exactly what you're missing. Free, no account required.

$curl -fsSL https://getpolyglot.ai/install.sh | bash
Translating React data arrays: stop trying to call t() in module scope - Polyglot Blog | Polyglot