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.
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:
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:
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
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
localStorageor 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=0in 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.
Free API key — start building in 30 seconds
500 req/month free · 1,321 exercises · GIF animations · No credit card