I run regularly and track every workout with my Apple Watch, which feeds into the Apple Health and Fitness apps on my iPhone. Over time I've built up a detailed log of my runs, and I wanted to dig into that data beyond what the apps show me. Apple Health lets you export everything as a large XML file, so I decided to use it as the basis for a project focused on data fetching and routing in React — parsing the file in the browser and displaying the results across two routes.

This post covers the key concepts I used to make that work.

The Data Source — Apple Health XML

Apple Health exports everything as export.xml — heart rate readings, step counts, sleep data, workouts — all in one file. A running workout entry looks like this:

<Workout workoutActivityType="HKWorkoutActivityTypeRunning"
         duration="52.91"
         startDate="2025-07-31 14:52:35 +0000">

  <MetadataEntry key="HKElevationAscended" value="8197 cm"/>

  <WorkoutStatistics type="HKQuantityTypeIdentifierHeartRate"
                     average="177.48" unit="count/min"/>

  <WorkoutStatistics type="HKQuantityTypeIdentifierDistanceWalkingRunning"
                     sum="6.02" unit="mi"/>
</Workout>

The structure has two levels — elements (the tags themselves) and attributes (the data on those tags). Extracting data is always the same two-step pattern: find the element with querySelector, then read the attribute with getAttribute.

Parsing XML with DOMParser

The browser has a built-in DOMParser API that converts an XML string into a queryable document — exactly like the DOM for HTML. This means familiar methods like querySelector and querySelectorAll work on XML data.

I put the parsing logic in a dedicated utility function in utils/parseXML.js:

export function parseRunningData(xmlString) {
    const parser = new DOMParser();
    const data = parser.parseFromString(xmlString, "application/xml");

    const workouts = data.querySelectorAll(
        "Workout[workoutActivityType='HKWorkoutActivityTypeRunning']"
    );

    const runs = Array.from(workouts).map(workout => {
        const durationRaw = parseFloat(workout.getAttribute("duration"));
        const durationMins = Math.floor(durationRaw);
        const durationSecs = Math.round((durationRaw - durationMins) * 60);

        return {
            date: workout.getAttribute("startDate").split(" ")[0],
            duration: `${durationMins}m ${durationSecs}s`,
            heartRate: workout.querySelector(
                'WorkoutStatistics[type="HKQuantityTypeIdentifierHeartRate"]'
            )?.getAttribute("average") ?? "Unknown",
            distance: (
                parseFloat(workout.querySelector(
                    'WorkoutStatistics[type="HKQuantityTypeIdentifierDistanceWalkingRunning"]'
                )?.getAttribute("sum")) * 1.60934
            ).toFixed(2) ?? "Unknown",
            elevationAscended: (
                parseFloat(workout.querySelector(
                    'MetadataEntry[key="HKElevationAscended"]'
                )?.getAttribute("value")) / 100
            ).toFixed(1) ?? "Unknown"
        };
    });

    return runs;
}

A few things worth noting here.

querySelectorAll with attribute selectors — because there are multiple WorkoutStatistics elements on each workout, you need to target the right one using its type attribute as a filter, e.g. [type="HKQuantityTypeIdentifierHeartRate"]. The same CSS attribute selector syntax you'd use in a stylesheet works here.

Array.from()querySelectorAll returns a NodeList, not a true array. Wrapping it in Array.from() converts it so you can chain .map(), .filter() and so on.

Optional chaining ?. — not every workout will have every field. Using ?. before .getAttribute() means if querySelector returns null, it stops gracefully rather than throwing a TypeError.

Data conversions — the XML stores distance in miles and elevation in centimetres, so both need converting before they're useful. Distance is multiplied by 1.60934 for kilometres, elevation divided by 100 for metres.

A Custom Hook for Fetching

Rather than fetching data directly inside a component, I put the fetch logic in a custom hook at hooks/useRunData.js. The hook handles the async work and returns three values — the data, a loading flag, and any error — so components can respond to all three states.

export function useRunData() {
    const [runs, setRuns] = useState([]);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(null);

    useEffect(() => {
        async function fetchData() {
            try {
                const response = await fetch("export.xml");

                if (!response.ok) {
                    throw new Error(`Failed to fetch: ${response.status}`);
                }

                const xmlString = await response.text();
                const runs = parseRunningData(xmlString);
                setRuns(runs);
            } catch (error) {
                setError(error.message);
            } finally {
                setLoading(false);
            }
        }

        fetchData();
    }, []);

    return { runs, loading, error };
}

Two things worth calling out. First, you can't make the useEffect callback itself async — the pattern is to define an async function inside it and call it immediately. Second, finally runs regardless of whether the try succeeded or failed, which means setLoading(false) only needs to be written once rather than in both the try and catch blocks.

I also added a response.ok check after the fetch. fetch only rejects on network failures — it won't throw on a 404. Checking response.ok catches those cases before trying to parse a failed response.

Lifting State Up

My initial approach was to call useRunData in each page component. That caused the XML file to be fetched twice — once on the list page and again on the detail page. The Network tab made this obvious.

The fix is to fetch once at the top level and pass the data down as props:

export default function App() {
    const { runs, loading, error } = useRunData();

    return (
        <BrowserRouter>
            <Routes>
                <Route path="/" element={<Home runs={runs} loading={loading} error={error} />} />
                <Route path="/run/:id" element={<RunDetail runs={runs} loading={loading} error={error} />} />
            </Routes>
        </BrowserRouter>
    );
}

This is the same lifting state up pattern I used in the water tracker — when multiple components need the same data, it lives in the closest common ancestor.

Routing with React Router

The app has two routes. The index route shows a list of runs with date, distance and duration. The detail route shows the full metrics for a single run.

<Route path="/" element={<Home />} />
<Route path="/run/:id" element={<RunDetail />} />

The :id in the second route is a URL parameter — dynamic, so /run/0, /run/1 and so on all match the same route. On the detail page, useParams() reads that value:

const { id } = useParams();
const run = runs[Number(id)];

One gotcha here: useParams() always returns strings. Using id directly as an array index won't work as expected — runs["3"] and runs[3] behave differently in JavaScript. Wrapping in Number() fixes it.

Project Structure

src/
├── components/
│   └── RunCard.jsx
├── pages/
│   ├── Home.jsx
│   └── RunDetail.jsx
├── hooks/
│   └── useRunData.js
├── utils/
│   └── parseXML.js
└── App.jsx

Separating parsing (parseXML.js), fetching (useRunData.js), and rendering (pages and components) keeps each file focused on one thing. It also means the XML parsing logic is completely independent of React — it's just a function that takes a string and returns an array.

Summary

  • DOMParser lets you query XML with the same querySelector API you use for HTML — no third-party library needed
  • Attribute selectors in querySelectorAll are the right way to target specific elements when multiple siblings share the same tag name
  • Optional chaining is essential when working with real-world data that isn't guaranteed to be complete
  • Custom hooks keep fetch logic out of components — components stay focused on rendering
  • finally in a try/catch/finally block is cleaner than calling the same cleanup in both try and catch
  • useParams returns strings — always convert to the right type before using URL parameters as array indices or numbers
  • Fetch once, pass down as props — calling the same hook in multiple components causes multiple fetches