I shipped a screen that plots your records over time. It worked perfectly on my phone.
Then a message arrived: "The chart area is just blank."
I could not reproduce it, because my own account had weeks of data behind it. The person writing to me had installed the app that morning. Working as an indie developer, my test device is almost always the least representative one I own.
The moment I loaded a dataset with a single entry, the line disappeared. The problem was not the charting library. It was four lines of axis math I had written myself.
When the values do not move, the axis height becomes zero
To draw a line chart you usually derive the vertical range from the minimum and maximum. That is exactly what I did.
const H = 200; // drawing area height in px
const naive = (values) => {
const min = Math.min(...values);
const max = Math.max(...values);
return { min, max, span: max - min, pxPerUnit: H / (max - min) };
};Here is what happens when you feed that function the inputs a real user can produce. These are actual results from running it in Node.
single point span= 0 pxPerUnit= Infinity isFinite= false
same value x3 span= 0 pxPerUnit= Infinity isFinite= false
all zeroes span= 0 pxPerUnit= Infinity isFinite= false
normal 7 days span= 6 pxPerUnit= 33.333333333333336
With seven days of varied data you get a sensible 33.3px per unit.
With one data point, with a value that repeats, or with everything at zero, the span collapses to 0. JavaScript does not throw on division by zero. It returns Infinity and moves on.
No error. The calculation simply continues.
The result of that division flows straight into your coordinates
An infinite scale factor becomes something worse on the next multiplication. Same run, same script:
4 * Infinity = Infinity
0 * Infinity = NaN
When the value itself is 0, y = (value - min) * pxPerUnit evaluates to NaN.
Both SVG and Canvas treat a NaN coordinate as "do not draw this." No line, no warning. The component renders successfully and the user sees an empty frame.
My first instinct was to blame a library version. It cost me roughly half a day before I looked at my own helper. Numeric code that fails silently is the hardest kind to find, because nothing in the logs suggests anything went wrong.
| Input state | span | Scale | What the user sees |
|---|---|---|---|
| Seven days of records | 6 | 33.3px/unit | Renders as expected |
| One record only | 0 | Infinity | No point, no line |
| Same value three days | 0 | Infinity | Not even a flat line |
| Padded with zeroes | 0 | Infinity | Coordinates become NaN |
Filling gaps with zero makes the chart say something else
Part of the collapse was my own doing. I was padding missing days with 0 so every series had exactly seven entries.
That was convenient for the array. It was dishonest on screen. "No record that day" and "the value was zero that day" are different facts to the person reading the chart.
For weight or sleep duration, a zero carves a spike straight down through the graph. For a counter, zero is often the truth.
I now write one line per metric describing which rule applies, mostly so that future me does not have to rethink it.
| Metric type | Missing day | Reason |
|---|---|---|
| Weight, temperature, sleep | Break the line | Zero is not a possible reading |
| Steps, counts, saved items | Treat as 0 | Zero occurrences is a real fact |
| Mood or subjective ratings | Break the line | Blank should not read as neutral |
One small layer in front of the chart
The fix lives in front of the charting library rather than inside its options, so that it survives a library swap.
It does two things: it enforces a minimum span, and it refuses input that cannot be drawn.
type Point = { x: number; y: number | null };
type Axis = { lo: number; hi: number; span: number; pxPerUnit: number };
export const buildAxis = (
values: number[],
height = 200,
minSpan = 1,
): Axis | null => {
const finite = values.filter((v) => Number.isFinite(v));
if (finite.length === 0) return null;
let lo = Math.min(...finite);
let hi = Math.max(...finite);
if (hi - lo < minSpan) {
const center = (hi + lo) / 2;
lo = center - minSpan / 2;
hi = center + minSpan / 2;
}
return { lo, hi, span: hi - lo, pxPerUnit: height / (hi - lo) };
};Measured output after the padding is applied. All three collapsed cases now produce a finite scale.
padded single {"lo":3.5,"hi":4.5,"span":1,"pxPerUnit":200}
padded same x3 {"lo":4.5,"hi":5.5,"span":1,"pxPerUnit":200}
padded all zero {"lo":-0.5,"hi":0.5,"span":1,"pxPerUnit":200}
padded normal {"lo":3,"hi":9,"span":6,"pxPerUnit":33.333333333333336}
A single value now draws a mark centered in the plot. An all-zero series draws its line near the bottom with a little breathing room.
Pick minSpan to match the unit of the metric: 100 for step counts, 1 for hours of sleep. I reuse this helper across the small apps I maintain, and the unit is the one argument I have never been able to hard-code. A hard-coded constant will look wrong the moment you reuse the component for a different unit.
The null return covers the case where no finite value exists at all. The caller then shows something other than a chart.
I keep this in a plain module with no rendering imports, so it is testable without mounting a component. Three assertions covering a single point, a repeated value, and an empty array take a minute to write, and they fail loudly the next time someone changes the padding rule.
One more habit that helped: log a warning whenever buildAxis returns null in a release build. Silent numeric failures do not show up in crash reports, so unless you emit the signal yourself, the only way you learn about a blank chart is when someone takes the trouble to write to you.
Sometimes the right answer is not to draw the chart
Being able to render something is not the same as that something being worth showing.
A line chart built from one record is a single dot with no line. It tells the reader nothing about their own progress, and it looks like a defect.
I set my floor at three points. Below that I show a short message instead: "Two more days and your chart appears." That gives someone a reason to come back.
const MIN_POINTS = 3;
export const TrendSection = ({ points }: { points: Point[] }) => {
const values = points.map((p) => p.y).filter((v): v is number => v !== null);
const axis = buildAxis(values, 200, 1);
if (values.length < MIN_POINTS || axis === null) {
return <TrendPlaceholder remaining={MIN_POINTS - values.length} />;
}
return <TrendChart points={points} axis={axis} />;
};This branch belongs to the same problem space as empty state design. Loading, no records, and not-enough-records look alike but mean different things, and merging them confuses people. I covered how to separate them in designing empty states for Rork apps. If you need grouped series in a bar chart, rendering multiple datasets side by side with react-native-chart-kit covers that implementation.
If you only do one thing after reading this, build three fixtures: one point, three identical values, and all zeroes. Open your chart screen with each. Three taps will save you the half day it cost me.
For the layer underneath, where the records themselves are defined and typed across several apps, I wrote up that structure in a typed analytics event layer across six Rork apps.