SoulSync is an open-source mood tracker on Android. Its Statistics tab started life on react-native-chart-kit, the default answer to "how do I draw a line chart in React Native". It's a fine default right up until you want the chart to do something.

What was actually wrong with the library chart
Four defects: one flat stroke whatever the value, a bezier that overshot the data, a slot-indexed x axis, and no way to scrub it. Only the first is cosmetic.
One flat stroke, whatever the value. A mood of 3 and a mood of 8 were painted in the same colour at the same weight. Users kept saying the line was "not bright enough", which turned out to mean "I can't tell the good days from the bad ones".
The bezier overshot the data. A smooth curve between a 4 and a 9 dips below 4 on the way in. On a 0 to 10 mood scale that's a mood the user never had.
The x axis was slot-indexed, not a time axis. My query only returns days that were logged. Plot those consecutively and a three-month silence sits the same distance from its neighbour as a one-day silence. Worse, the "14-day moving average" I was labelling was really a 14-entry average. It was quietly wrong on exactly the sparse data where a trend line matters most.
No way in. Users wanted to hold the chart and drag along it, reading each day as they went. 6.x does give you onDataPointClick and a decorator prop for painting extra SVG on top, which covers tapping a dot. What it doesn't hand you is the touch stream or the x-to-index mapping, so a continuous scrub with nearest-point hit-testing isn't something you can build on top of it. The library also sized itself from Dimensions.get('window') instead of measuring its own container, so it never quite fit its card.
The part where I was wrong about the library
I removed chart-kit believing it was unmaintained. That belief was two months stale, and I should say so plainly.
Here's the real release history from npm. Version 6.12.0 shipped in February 2022 and then nothing moved for four years, which is where my impression of the project froze. It woke up in 2026: 6.12.1 in April, 6.12.3 in May, a next train through May and June, and v7.0.0 on 27 June 2026. It's on 7.0.2 now with 133k weekly downloads.
It gets worse for me. My lockfile had carried the app to 6.12.3, a May 2026 release, so I wasn't even running the four-year-old code I thought I was. What I was running was the pre-v7 architecture. The 6.12.x releases are maintenance work on the original LineChart, and the rewrite lives behind v7's /v2 subpath, which 6.12.3 doesn't ship at all.
v7 is a substantial rewrite behind a react-native-chart-kit/v2 subpath. Its release notes list "multi-series data, null gaps, smart labels, tooltips, crosshair, scrollable viewports, pan/zoom controls, range selector, markers, reference overlays, thresholds, decimation, and accessibility helpers", plus renderer-agnostic core packages for scales, layout, geometry and interaction. Read that against my list above. Null gaps and a crosshair address defects three and four directly. If I'd checked npm instead of my memory of npm, "upgrade" was a real option I never priced.
I'd still make the same call, and the reasons that survive are better than the one I used:
- The colour ramp is shared app state, not chart config. The line is painted from the same mood ramp as the timeline dots and the heatmap cells. A theming API gives me a second palette that drifts from the first one on the next redesign.
- Half the geometry already existed. The Home tab's week chart left chart-kit three months earlier, and its pure
chartGeometry.tswas sitting right there. The marginal cost of the second chart was 750 lines, not 1,355. - A library that owns pan and zoom is a third competitor for the same finger. More on that below. It's the reason I'd have had to fight v7's interaction layer rather than use it.
That's a narrower claim than "the library is dead", and it's the one I can defend.
The other obvious move was a different library. victory-native does 508k weekly downloads and react-native-gifted-charts does 248k, and both handle gestures properly. Same three reasons apply to both, plus one more: swapping libraries is the same migration cost as writing it, without the part where I get to keep the geometry.
Split the geometry out before you draw anything
No coordinate math lives in the component. Every domain, gridline, gradient stop and hit-test is a pure function in a transforms/ folder with zero React or React Native imports. The renderer is thin enough to be boring.
That's not architecture for its own sake. A chart's bugs are almost all arithmetic, and arithmetic is the part you can test without a screen. Here's the vertical domain resolver, which answers "what range does this axis cover":
export const resolveDomain = (
values: readonly (number | null | undefined)[],
mode: DomainMode
): ValueDomain => {
if (mode === 'fixed') return MOOD_DOMAIN; // always 0..10
let min = Infinity, max = -Infinity;
for (const v of values) {
if (typeof v !== 'number' || !Number.isFinite(v)) continue;
if (v < min) min = v;
if (v > max) max = v;
}
if (min === Infinity) return MOOD_DOMAIN; // empty DB is a real code path
const pad = Math.max(FIT_PAD_MIN, (max - min) * FIT_PAD_RATIO);
let lo = clamp(Math.floor(min - pad), MOOD_MIN, MOOD_MAX);
let hi = clamp(Math.ceil(max + pad), MOOD_MIN, MOOD_MAX);
// Widen to a minimum span. A two-point series spanning 0.2 of a mood
// would otherwise render as a dramatic mountain range.
while (hi - lo < FIT_MIN_SPAN) {
if (hi < MOOD_MAX) hi += 1;
else if (lo > MOOD_MIN) lo -= 1;
else break;
}
return { min: lo, max: hi };
};
Two modes, because they answer different questions. fixed is always 0 to 10, so this week and last week stay comparable at a glance. fit zooms to the data's own range, which is what you want when every day was a 6 or a 7. FIT_MIN_SPAN is 3, because a fitted domain of 6.1 to 6.3 turns statistical noise into a mountain range.
The bounds snap to integers, so axis labels are whole moods and never "6.37".
Colour that carries information
The line is painted with a vertical gradient built from the app's single mood ramp, so height reads as colour and not just position. High is green, low is amber and red. There's deliberately no second palette.
The interesting part is a constant:
/**
* The canonical mood ramp bottoms out at 0.2 alpha. A fill can live there;
* a 3px stroke cannot, and that faintness IS the "not bright enough"
* complaint. The ramp's shape is preserved, its floor is raised.
*
* 0.85 (was 0.55): at 0.55 the stroke visibly DIMMED as it descended, so a
* bad week looked like a rendering fault rather than a low mood.
*/
const LINE_MIN_OPACITY = 0.85;
I shipped 0.55 first and it was wrong. A ramp tuned for filled shapes doesn't transfer to a 3px stroke, because a stroke has almost no area to carry the alpha. The fix keeps the ramp's shape and rescales it into a legible opacity window, instead of inventing a second ramp that would drift from the rest of the app.

Hold to scrub, tap to expand
A long-press-then-drag Pan from react-native-gesture-handler gives you the whole scrub interaction in six lines, and a ref keeps it from re-rendering the chart on every pointer sample.
const scrub = Gesture.Pan()
.activateAfterLongPress(SCRUB_ACTIVATE_MS) // 220
.runOnJS(true)
.onStart((e) => handleScrub(e.x))
.onUpdate((e) => handleScrub(e.x))
.onFinalize(endScrub);
// Race, not Simultaneous: a quick tap expands, a hold scrubs, never both.
return onPress ? Gesture.Race(scrub, tap) : scrub;
runOnJS(true) is deliberate, not laziness. The readout is React state and the haptic is a JS call, so there's nothing here worth a worklet.
The ref is what stops a re-render storm. Gesture callbacks write the current index to scrubIndexRef and only call setState when the index actually changes. On a slow drag across a month that's about 30 updates instead of thousands.
Hit-testing is a separate pure function, and its comments are mostly about edges:
export const nearestIndex = (x: number, xs: readonly number[]): number | null => {
if (xs.length === 0) return null;
let best = 0, bestDist = Math.abs(xs[0] - x);
for (let i = 1; i < xs.length; i++) {
const d = Math.abs(xs[i] - x);
// Strictly-less keeps the EARLIER point on an exact tie, so a scrub
// across a midpoint switches once, at the midpoint, in both directions.
if (d < bestDist) { best = i; bestDist = d; }
}
return best;
};
xs holds only the real data points, so a hold can never report a mood for a day that was never logged. Dragging past either end clamps to that end rather than dropping the cursor, because a cursor that vanishes mid-drag reads as a bug.
One product decision is buried in there. The tooltip shows the day's average and the last entry the user actually wrote that day, with its time and the first line of its note. An average isn't what anyone remembers about a Tuesday. The entry is.

Two Pan gestures, one finger
Two Pan handlers and a vertical ScrollView can share one finger without either gesture knowing the other exists. Three numbers and one RNGH rule do all the work.

I added a second gesture to this screen after the chart: swipe sideways to step through periods. So now the scrub pan, the page pan and the scroller all want the same touch.
The rule: when a non-simultaneous handler activates, every other handler still in BEGAN gets cancelled. The only question is which one activates first.
The numbers: the scrub needs a 220 ms hold, the page pan needs 24 px of horizontal travel (activeOffsetX), and 12 px of vertical travel fails it outright (failOffsetY). Hold still and the scrub arrives long before your finger drifts 24 px. Flick sideways and the page pan wins in well under 220 ms. Drag downward and the pan fails, so the ScrollView keeps the touch and scrolls as it always did.
The page pan is attached to a plain View that's an ancestor of the ScrollView, not to the ScrollView itself. Two handlers on one native view tag makes arbitration depend on registration order. On an ancestor it's unambiguous. The ScrollView is RNGH's rather than React Native's, so vertical scrolling joins the same arbitration instead of running its own.
These thresholds are load-bearing in a way that's invisible in review. The comment above ACTIVE_OFFSET_X says it out loud: don't lower it, and don't mark either gesture simultaneous with anything.
The Fabric trap that sent me back to the old Animated API
The page transition uses React Native's built-in Animated with useNativeDriver: true, not Reanimated. That looks like a regression. It isn't.
A live Reanimated useAnimatedStyle on a flex: 1 container blanks this screen. The Statistics tab has about eight charts, each resolving its own async query and re-laying-out over roughly three seconds after mount. On one of those re-layouts, Reanimated applies its animated props against a stale measured frame and shoves the whole subtree about 1,600 px off-screen. The tab goes blank with no JS re-render at all, so nothing in the React tree looks wrong.
I root-caused it on device after shipping it. The property being animated is irrelevant: an opacity-only animated style reproduces it. Only removing the animated style from the flex: 1 view fixes it. Lighter screens share the same code path and never reproduce, because their content doesn't repeatedly re-lay-out after mount.
Native-driven Animated doesn't have the bug. The transform is applied by the platform animation module to the view's own node, and is never recomputed from a JS-side measured layout. So the swipe uses it, with a long comment explaining why nobody should modernise it.
Testing a chart without taking a screenshot
Pure geometry means the chart's tests are ordinary unit tests asserting invariants, not pixel snapshots. There are 131 of them across the chart transforms and renderers. A sample of the names:
- gradient stop opacity decreases monotonically from top to bottom
- a fitted domain contains every data point it was fitted to
- an exact midpoint resolves to the earlier point, in both directions
nearestIndexis monotonic: sweeping right never moves the index left- the tooltip never leaves the container, for any anchor
buildGridLinesreturns nothing for a degenerate domain instead of looping forever
The one I'd recommend to anyone doing a migration like this isn't a geometry test. It's a source-level guard:
const IMPORTS_CHART_KIT =
/(?:from\s+['"]react-native-chart-kit['"]|require\(\s*['"]react-native-chart-kit['"]\s*\))/;
it('scans a non-trivial number of files (guards against a vacuous pass)', () => {
expect(allFiles.length).toBeGreaterThan(50);
});
it('no app source imports it', () => {
const offenders = allFiles.filter((f) =>
IMPORTS_CHART_KIT.test(readFileSync(f, 'utf8'))
);
expect(offenders.map((f) => f.replace(`${ROOT}/`, ''))).toEqual([]);
});
it('the regex really does catch an import (proves the check has teeth)', () => {
expect(IMPORTS_CHART_KIT.test(`import { LineChart } from '${CHART_KIT}';`)).toBe(true);
// A comment naming it is fine. This file and several others do.
expect(IMPORTS_CHART_KIT.test(`// replaces ${CHART_KIT}'s LineChart`)).toBe(false);
});
The dependency is gone from package.json, so a stray import fails at bundle time anyway. But that failure is a confusing "module not found" in Metro. This test states the actual rule, so the next new chart in this repo hears about it in review.
Note the two supporting tests. One proves the file walker found something, so the check can't pass vacuously. One proves the regex has teeth. A guard test that silently stops guarding is worse than no guard, because you stop checking by hand.
What I'd do differently
Check the registry, not your memory of the registry. I called a library unmaintained in a code comment, a changelog and a test docstring. It had shipped a major version two months before I wrote any of them.
The second thing: I spent a while theming around the library before replacing it. Colour props, custom dot renderers, wrappers to fight the sizing. All of it was thrown away.
The heuristic I'd use now has two parts. If two consecutive feature requests need the library to change its geometry or its gesture handling, stop theming and measure how much of the library you actually use. And before you write the word "unmaintained" anywhere, open npm.
Open question, and I'd genuinely like other opinions. I kept the scrub on the JS thread because the readout is React state. If you've moved this kind of scrub fully into a worklet with a shared value driving the SVG cursor, was the complexity worth it on a mid-range Android device? Or is 60 Hz on the JS thread plenty for a gesture this coarse?
The app is open source if you want to read the whole thing: github.com/Antimatter543/mood-tracker. It's on Google Play too.
I write these from real work at astraedus.dev, where I build apps and tools. Building something, or stuck on something like this? Reach me at astraedus.dev or [email protected].
Get the next one in your inbox → subscribe at astraedus.dev.