Tutorial April 29, 2026 · 10 min read

How to Use an Exercise API in React: Complete Tutorial with GIFs & Filtering

Build a full-featured exercise browser in React — fetch exercises, display animated GIFs, filter by body part and muscle, add search, and paginate results. Uses the free WorkoutX API with real working code.

How to Use an Exercise API in React Cover Image - WorkoutX Blog
bolt Quick Summary (TL;DR)

A complete hands-on tutorial for building an exercise browser in React 18+. Walk through building a custom API fetch hook, rendering GIFs, adding pagination controls, and filtering by muscle group.

Prerequisites

  • React 18+ (Create React App or Vite)
  • Basic knowledge of React hooks (useState, useEffect)
  • A free WorkoutX API key — takes 30 seconds

Free tier is enough for this tutorial. The WorkoutX free plan gives you 500 requests/month — more than enough to build and test this entire exercise browser.

Step 1 — Project Setup

Create a new React project and set up your API key as an environment variable:

npx create-react-app exercise-browser
cd exercise-browser

Create a .env file in the project root:

REACT_APP_WORKOUTX_KEY=wx_your_api_key_here

Security note: Never commit your API key to Git. Add .env to your .gitignore. For production apps, proxy API calls through your own backend to keep the key server-side.

Step 2 — Create a Custom API Hook

Start by building a reusable useExerciseAPI hook that handles fetching, loading, and error state. This keeps your components clean.

src/hooks/useExerciseAPI.js
import { useState, useCallback } from 'react';

const BASE_URL = 'https://api.workoutxapp.com';
const API_KEY = process.env.REACT_APP_WORKOUTX_KEY;

export function useExerciseAPI() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const request = useCallback(async (endpoint, params = {}) => {
    setLoading(true);
    setError(null);

    const url = new URL(`${BASE_URL}${endpoint}`);
    Object.entries(params).forEach(([k, v]) => {
      if (v !== undefined && v !== '') url.searchParams.set(k, v);
    });

    try {
      const res = await fetch(url.toString(), {
        headers: { 'X-WorkoutX-Key': API_KEY },
      });

      if (!res.ok) {
        const err = await res.json().catch(() => ({}));
        throw new Error(err.message || `HTTP ${res.status}`);
      }

      return await res.json();
    } catch (err) {
      setError(err.message);
      return null;
    } finally {
      setLoading(false);
    }
  }, []);

  return { request, loading, error };
}

Step 3 — Exercise List with Body Part Filter

Build the main exercise browser component with filtering by body part:

src/components/ExerciseBrowser.jsx
import { useState, useEffect } from 'react';
import { useExerciseAPI } from '../hooks/useExerciseAPI';
import ExerciseCard from './ExerciseCard';

const BODY_PARTS = [
  'All', 'Back', 'Cardio', 'Chest', 'Lower Arms',
  'Lower Legs', 'Neck', 'Shoulders', 'Upper Arms',
  'Upper Legs', 'Waist'
];

export default function ExerciseBrowser() {
  const { request, loading, error } = useExerciseAPI();
  const [exercises, setExercises] = useState([]);
  const [total, setTotal] = useState(0);
  const [bodyPart, setBodyPart] = useState('All');
  const [search, setSearch] = useState('');
  const [page, setPage] = useState(0);
  const LIMIT = 12;

  useEffect(() => {
    async function fetchExercises() {
      let endpoint;
      let params = { limit: LIMIT, offset: page * LIMIT };

      if (search.trim()) {
        // Use name search when user types
        endpoint = `/v1/exercises/name/${encodeURIComponent(search.trim())}`;
      } else if (bodyPart !== 'All') {
        endpoint = `/v1/exercises/bodyPart/${encodeURIComponent(bodyPart)}`;
      } else {
        endpoint = '/v1/exercises';
      }

      const data = await request(endpoint, params);
      if (data) {
        setExercises(data.data || []);
        setTotal(data.total || 0);
      }
    }

    fetchExercises();
  }, [bodyPart, search, page, request]);

  // Reset to page 0 when filter changes
  const handleBodyPartChange = (bp) => {
    setBodyPart(bp);
    setSearch('');
    setPage(0);
  };

  const handleSearch = (e) => {
    setSearch(e.target.value);
    setPage(0);
  };

  return (
    <div style={{ maxWidth: 1100, margin: '0 auto', padding: '24px 16px' }}>
      <h1>Exercise Browser</h1>
      <p style={{ color: '#888' }}>{total} exercises</p>

      {/* Search */}
      <input
        type="text"
        placeholder="Search exercises..."
        value={search}
        onChange={handleSearch}
        style={{
          width: '100%', padding: '10px 14px', borderRadius: 8,
          border: '1px solid #333', background: '#1a1a1a',
          color: '#fff', fontSize: 15, marginBottom: 16
        }}
      />

      {/* Body Part Filter */}
      <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 24 }}>
        {BODY_PARTS.map(bp => (
          <button
            key={bp}
            onClick={() => handleBodyPartChange(bp)}
            style={{
              padding: '6px 16px', borderRadius: 99, fontSize: 13,
              border: bodyPart === bp ? 'none' : '1px solid #333',
              background: bodyPart === bp
                ? 'linear-gradient(135deg, #f5f5f7, rgba(255,255,255,0.4))'
                : 'transparent',
              color: bodyPart === bp ? '#1000a9' : '#aaa',
              fontWeight: bodyPart === bp ? 700 : 400,
              cursor: 'pointer',
            }}
          >
            {bp}
          </button>
        ))}
      </div>

      {/* Error */}
      {error && (
        <div style={{ color: '#ff6b6b', padding: 16, background: '#1a0000', borderRadius: 8 }}>
          Error: {error}
        </div>
      )}

      {/* Loading */}
      {loading && (
        <div style={{ textAlign: 'center', color: '#888', padding: 40 }}>
          Loading exercises...
        </div>
      )}

      {/* Grid */}
      {!loading && (
        <div style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
          gap: 20
        }}>
          {exercises.map(ex => (
            <ExerciseCard key={ex.id} exercise={ex} />
          ))}
        </div>
      )}

      {/* Pagination */}
      {total > LIMIT && (
        <div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 32 }}>
          <button
            onClick={() => setPage(p => Math.max(0, p - 1))}
            disabled={page === 0}
            style={{
              padding: '8px 20px', borderRadius: 8, border: '1px solid #333',
              background: 'transparent', color: page === 0 ? '#555' : '#fff',
              cursor: page === 0 ? 'not-allowed' : 'pointer'
            }}
          >
            ← Previous
          </button>
          <span style={{ color: '#888', lineHeight: '36px', fontSize: 13 }}>
            Page {page + 1} of {Math.ceil(total / LIMIT)}
          </span>
          <button
            onClick={() => setPage(p => p + 1)}
            disabled={(page + 1) * LIMIT >= total}
            style={{
              padding: '8px 20px', borderRadius: 8, border: '1px solid #333',
              background: 'transparent',
              color: (page + 1) * LIMIT >= total ? '#555' : '#fff',
              cursor: (page + 1) * LIMIT >= total ? 'not-allowed' : 'pointer'
            }}
          >
            Next →
          </button>
        </div>
      )}
    </div>
  );
}

Step 4 — Exercise Card with GIF

Build the ExerciseCard component that shows the GIF animation and exercise details:

src/components/ExerciseCard.jsx
import { useState } from 'react';

export default function ExerciseCard({ exercise }) {
  const [gifError, setGifError] = useState(false);
  const [showInstructions, setShowInstructions] = useState(false);

  return (
    <div style={{
      background: '#1a1a1a', borderRadius: 12,
      border: '1px solid #2a2a2a', overflow: 'hidden',
      transition: 'transform 0.2s, border-color 0.2s',
    }}
      onMouseEnter={e => e.currentTarget.style.borderColor = '#5a5aff'}
      onMouseLeave={e => e.currentTarget.style.borderColor = '#2a2a2a'}
    >
      {/* GIF */}
      <div style={{ position: 'relative', background: '#111', height: 220 }}>
        {!gifError ? (
          <img
            src={exercise.gifUrl}
            alt={`${exercise.name} demonstration`}
            loading="lazy"
            onError={() => setGifError(true)}
            style={{
              width: '100%', height: '100%',
              objectFit: 'cover', display: 'block'
            }}
          />
        ) : (
          <div style={{
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            height: '100%', color: '#555', fontSize: 13
          }}>
            GIF unavailable
          </div>
        )}

        {/* Difficulty badge */}
        <span style={{
          position: 'absolute', top: 10, right: 10,
          padding: '3px 10px', borderRadius: 99, fontSize: 11,
          fontWeight: 700, textTransform: 'uppercase',
          background: exercise.difficulty === 'beginner'
            ? 'rgba(255,255,255,0.2)'
            : exercise.difficulty === 'intermediate'
              ? 'rgba(255,255,255,0.2)'
              : 'rgba(255,100,100,0.2)',
          color: exercise.difficulty === 'beginner'
            ? '#f5f5f7'
            : exercise.difficulty === 'intermediate'
              ? '#f5f5f7'
              : '#ff6b6b',
        }}>
          {exercise.difficulty}
        </span>
      </div>

      {/* Info */}
      <div style={{ padding: '14px 16px' }}>
        <h3 style={{ margin: '0 0 6px', fontSize: 15, fontWeight: 700, color: '#f5f5f7' }}>
          {exercise.name}
        </h3>
        <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
          <Tag color="#f5f5f7">{exercise.bodyPart}</Tag>
          <Tag color="#f5f5f7">{exercise.target}</Tag>
          <Tag color="#515154">{exercise.equipment}</Tag>
        </div>

        {/* Calorie info */}
        {exercise.caloriesPerMinute && (
          <p style={{ margin: '0 0 10px', fontSize: 12, color: '#888' }}>
            🔥 ~{exercise.caloriesPerMinute} kcal/min
            · {exercise.mechanic} · {exercise.force}
          </p>
        )}

        {/* Instructions toggle */}
        <button
          onClick={() => setShowInstructions(s => !s)}
          style={{
            background: 'none', border: '1px solid #333', borderRadius: 6,
            color: '#f5f5f7', fontSize: 12, padding: '5px 12px', cursor: 'pointer',
            width: '100%'
          }}
        >
          {showInstructions ? 'Hide' : 'Show'} instructions
        </button>

        {showInstructions && (
          <ol style={{ margin: '12px 0 0', paddingLeft: 18 }}>
            {exercise.instructions.map((step, i) => (
              <li key={i} style={{ color: '#aaa', fontSize: 12, lineHeight: 1.6, marginBottom: 4 }}>
                {step}
              </li>
            ))}
          </ol>
        )}
      </div>
    </div>
  );
}

function Tag({ children, color }) {
  return (
    <span style={{
      padding: '2px 8px', borderRadius: 99, fontSize: 11, fontWeight: 600,
      background: `${color}18`, color, border: `1px solid ${color}33`
    }}>
      {children}
    </span>
  );
}

Step 5 — Wire It Up

src/App.js
import ExerciseBrowser from './components/ExerciseBrowser';

function App() {
  return (
    <div style={{ minHeight: '100vh', background: '#111', color: '#fff' }}>
      <ExerciseBrowser />
    </div>
  );
}

export default App;
npm start

Your exercise browser is now running at http://localhost:3000 with filtering, search, GIF animations, and pagination.

Step 6 — Advanced: Multi-Filter Search

On Basic plan and above, use the /v1/exercises/search endpoint to filter by multiple criteria simultaneously — great for "show me beginner chest exercises using dumbbells" (like a dumbbell bench press):

// Multi-filter search (requires Basic plan or higher)
const data = await request('/v1/exercises/search', {
  bodyPart: 'Chest',
  equipment: 'Dumbbell',
  difficulty: 'beginner',
  limit: 12,
  offset: 0,
});
const exercises = data.data;

Step 7 — Calorie Calculator

Use the calories endpoint to show users how many calories they'll burn doing an exercise. High-intensity moves like burpees and mountain climbers burn the most per minute — a great data point to surface in your UI:

// Get calorie estimate: weight in kg, duration in minutes
const calories = await request(
  `/v1/exercises/${exercise.id}/calories`,
  { weight: 75, minutes: 15 }
);
// Returns: { exerciseId, name, weightKg, minutes, calories, met }
console.log(`Burns ~${calories.calories} kcal in 15 minutes`);

Performance Tips

  • Debounce search input — wrap the search handler with a 300ms debounce to avoid a request on every keystroke
  • Lazy-load GIFs — use loading="lazy" on all exercise GIF <img> tags
  • Cache responses — store exercise data in localStorage or React Query's cache to avoid redundant API calls
  • Server-side key — in production, proxy API calls through your backend so the API key is never exposed in the browser
  • Pagination over "load all" — never call ?limit=0 in a browser app; fetch 10–20 exercises per page instead

What you built: A full exercise browser with GIF animations, body part filtering, name search, and pagination — using less than 150 lines of React code and the free WorkoutX API tier.

code

Free API key — start building in 30 seconds

500 req/month free · 1,400+ exercises · GIF animations · No credit card

Get Free API Key arrow_forward