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.

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, #c0c1ff, #8083ff)'
                : '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(78,222,163,0.2)'
            : exercise.difficulty === 'intermediate'
              ? 'rgba(192,193,255,0.2)'
              : 'rgba(255,100,100,0.2)',
          color: exercise.difficulty === 'beginner'
            ? '#4edea3'
            : exercise.difficulty === 'intermediate'
              ? '#c0c1ff'
              : '#ff6b6b',
        }}>
          {exercise.difficulty}
        </span>
      </div>

      {/* Info */}
      <div style={{ padding: '14px 16px' }}>
        <h3 style={{ margin: '0 0 6px', fontSize: 15, fontWeight: 700, color: '#e5e1e5' }}>
          {exercise.name}
        </h3>
        <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
          <Tag color="#c0c1ff">{exercise.bodyPart}</Tag>
          <Tag color="#4edea3">{exercise.target}</Tag>
          <Tag color="#908fa0">{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: '#c0c1ff', 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":

// 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:

// 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,321 exercises · GIF animations · No credit card

Get Free API Key arrow_forward