- 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:
- There's a lot of duplicated code, even without including any styling yet.
- 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).
- 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).
- Population
- 973.8k
- Poverty Rate
- 11.3%
- Median Household Income
- $70,176
- Median Age
- 41.4
- Population
- 8.9m
- Poverty Rate
- 9.2%
- Median Household Income
- $85,751
- Median Age
- 40.2
- 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 ofDatum
. If we refactoredDatum
to use something other thannumeral
, 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.