Published on

Formatting Data in React Components

In most data-driven applications, eventually, the time will come when you need to format some of that data. React's flexibility gives you several options for abstracting that formatting into a reusable component. We'll look at some of those options in this post and weigh their pros and cons.

The Data

In this example, we'll look at a component that displays data for different states in the U.S. Here's an example of what that component might look like with no abstraction.

type StateInfo = {
  name: string
  population: number
  povertyRate: number
  medianHouseholdIncome: number
  medianAge: number
}

function StateInfo(state: StateInfo) {
  return (
    <div>
      <span>{state.name}</span>
      <dl>
        <dt>Population</dt>
        <dd>{numeral(state.population).format('0a')}</dd>
        <dt>Poverty Rate</dt>
        <dd>{numeral(state.povertyRate).format('0.0%')}</dd>
        <dt>Median Household Income</dt>
        <dd>{numeral(state.medianHouseholdIncome).format('$0,0')}</dd>
        <dt>Median Age</dt>
        <dd>{numeral(state.medianAge).format('0.0')}</dd>
      </dl>
    </div>
  )
}

You may notice a few issues stand out immediately here:

  1. There's a lot of duplicated code, even without including any styling yet.
  2. The dense text makes it hard to pick out the dynamic data (the values to be formatted) from the static data (the labels and data formats).
  3. The cryptic formats passed to numeral.format() make it hard to tell what the output of this component should be.

Here's how the output looks for a few different states (with some styles added).

Delaware
Population
973.8k
Poverty Rate
11.3%
Median Household Income
$70,176
Median Age
41.4
New Jersey
Population
8.9m
Poverty Rate
9.2%
Median Household Income
$85,751
Median Age
40.2
Pennsylvania
Population
12.8m
Poverty Rate
12.0%
Median Household Income
$63,463
Median Age
40.8

Extracting a Shared "Datum" Component

Your first draft implementation of Datum might take only two props—label and value—and leave the formatting to the consumer, but this only solves the first issue listed above. What characteristics would a more useful implementation have?

  • No need to duplicate HTML structure or CSS classes
  • Simple to use at the call site
  • Props should make it clear what's different between each use case
  • Scalable

That last point is worth taking a moment to elaborate on. What exactly do I mean by "scalable?" We currently have a limited set of requirements, so we don't want to over-engineer our component to do things we don't need yet. But we do want to put ourselves in a good position to add functionality later when our requirements change.

With these criteria in mind, here are the props I came up with for the Datum component:

type DatumProps = {
  label: string
  value: number
  format: string
}

And here's a first pass at an implementation:

function Datum({ label, value, format }: DatumProps) {
  return (
    <>
      <dt>{label}</dt>
      <dd>{numeral(value).format(format)}</dd>
    </>
  )
}

function StateInfo(state: StateInfo) {
  return (
    <div>
      <span>{state.name}</span>
      <dl>
        <Datum
          label="Population"
          value={state.population}
          format="0a"
        />
        <Datum
          label="Poverty Rate"
          value={state.povertyRate}
          format="0.0%"
        />
        <Datum
          label="Median Household Income"
          value={state.medianHouseholdIncome}
          format="$0,0"
        />
        <Datum
          label="Median Age"
          value={state.medianAge}
          format="0.0"
        />
      </dl>
    </div>
  )
}

Improving Readability

By separating the value and format props, we've made the call site more descriptive. You can almost read it like a sentence:

Take the value of 'median household income,' give it a label, and format it as currency.

You could almost predict what the output will be just by reading it. Almost. But there are a few drawbacks to the way we're passing the format prop:

  • We have a leaky abstraction. There's an implicit dependency on numeral that can be easy to miss without looking at the implementation of Datum. If we refactored Datum to use something other than numeral, we'd have to update every component that uses it as well.
  • It would be easy to pass an invalid format string, and there would be no feedback that it's invalid until numeral throws an error at runtime.
  • Some of these strings can be pretty cryptic for any developer not intimately familiar with numeral and its formatting.

Let's address these issues by extracting the format strings to named constants that live alongside the Datum component.

const Format = {
  largeNumber: '0a',
  percent: '0.0%',
  currency: '$0,0',
  decimal: '0.0',
}

// Datum's implementation is still the same

function StateInfo(state: StateInfo) {
  return (
    <div>
      <span>{state.name}</span>
      <dl>
        <Datum
          label="Population"
          value={state.population}
          format={Format.largeNumber}
        />
        <Datum
          label="Poverty Rate"
          value={state.povertyRate}
          format={Format.percent}
        />
        <Datum
          label="Median Household Income"
          value={state.medianHouseholdIncome}
          format={Format.currency}
        />
        <Datum
          label="Median Age"
          value={state.medianAge}
          format={Format.decimal}
        />
      </dl>
    </div>
  )
}

This version is much easier to understand by reading it. And by keeping the constants in the same file as Datum, we limit the scope of future changes if we need to update how we're formatting the numbers.

Handling Changes to Requirements

Now imagine that we've been asked to add two more data points to our StateInfo component: the date that the state joined the U.S. and the state's abbreviation. This means we'll have to change our implementation of Datum, because neither of these can be formatted by numeral.

At this point, it may be tempting to turn Format into an enum, something like this:

// Don't do this

const Format = {
  largeNumber: 'largeNumber',
  percent: 'percent',
  currency: 'currency',
  decimal: 'decimal',
  dateLong: 'dateLong',
  dateShort: 'dateShort',
  none: 'none',
}

function Datum({ label, value, format }: DatumProps) {
  return (
    <>
      <dt>{label}</dt>
      <dd>{formatValue(value, format)}</dd>
    </>
  )
}

function formatValue(value: number | Date, format: keyof typeof Format) {
  switch (format) {
    case 'largeNumber':
      return numeral(value).format('0a')
    // ... snip: a lot of other cases ...
    case 'dateLong':
      return new Intl.DateTimeFormat('default', { dateStyle: 'medium' }).format(
        value
      )
  }
}

This works, but it's hard to scale. For every new format you want to add, you need to add it in two places. Instead, we can turn the values in the Format object into functions that take a value and format it for us.

const Format = {
  largeNumber: (value: number) => numeral(value).format('0a'),
  percent: (value: number) => numeral(value).format('0.0%'),
  currency: (value: number) => numeral(value).format('$0,0'),
  decimal: (value: number) => numeral(value).format('0.0'),
  dateLong: (value: number | Date) =>
    new Intl.DateTimeFormat('default', { dateStyle: 'medium' }).format(
      value
    ),
  dateShort: (value: number | Date) =>
    new Intl.DateTimeFormat('default', { dateStyle: 'short' }).format(
      value
    ),
}

type DatumProps<Value> = {
  label: string
  value: Value
  format?: (value: Value) => string
}

function Datum<Value>({ label, value, format = identity }: DatumProps<Value>) {
  return (
    <>
      <dt>{label}</dt>
      <dd>{format(value)}</dd>
    </>
  )
}

function StateInfo(state: StateInfo) {
  return (
    <div>
      <span>{state.name}</span>
      <dl>
        <Datum
          label="Abbreviation"
          value={state.abbreviation}
        />
        <Datum
          label="Population"
          value={state.population}
          format={Format.largeNumber}
        />
        <Datum
          label="Poverty Rate"
          value={state.povertyRate}
          format={Format.percent}
        />
        <Datum
          label="Median Household Income"
          value={state.medianHouseholdIncome}
          format={Format.currency}
        />
        <Datum
          label="Median Age"
          value={state.medianAge}
          format={Format.decimal}
        />
        <Datum
          label="Date Joined"
          value={state.dateJoined}
          format={Format.dateLong}
        />
      </dl>
    </div>
  )
}

There! Now we can add new formats simply by adding a new property to the Format object. Another benefit to this pattern is that you can provide it inline at the call site for one-off formats that aren't worth adding to the "official" set. Or, if you've already got a group of named formatting functions defined elsewhere, you can skip the Format object and use those. No more accidentally providing an invalid format!

You might be asking yourself, "But couldn't you just pass the formatted value directly, like value={Format.dateLong(state.dateJoined)}, and skip the format prop?" You could, and my version doesn't have many advantages over that version. The main benefit I mentioned earlier is that this makes the call site read more like a sentence and more manageable for someone to parse when skimming the code. For example, consider how changing the format would look in a pull request:

 <Datum
   label="Date Joined"
-  value={Format.dateLong(state.dateJoined)}
+  value={Format.dateShort(state.dateJoined)}
 />

Compare that to this one, which I think makes it more transparent exactly how the output will have changed:

 <Datum
   label="Date Joined"
   value={state.dateJoined}
-  format={Format.dateLong}
+  format={Format.dateShort}
 />

Conclusion

So which is the right choice? To be honest, there isn't one abstraction that's the perfect fit for every situation. You have to weigh the pros and cons against your particular requirements to choose. And more often than not, those requirements will change, and (hopefully) so will your abstraction.