How to make your MDX pages interactive — conditional rendering, mapping arrays, dynamic styles, state, and data fetching.
This is where MDX stops being "fancy Markdown" and becomes something actually powerful. You can show/hide content based on conditions, loop over data, respond to clicks, and fetch live data — all inside your content files.
Let's cover the patterns you'll actually use.
Conditional Rendering
You need to show or hide things based on some condition. Since JSX only accepts expressions (not statements like if/else), you've got two main tools: ternaries and &&.
Ternary Operator
The ternary (condition ? ifTrue : ifFalse) is your go-to for "show this OR that":
export const isLoggedIn = true
export const userRole = "admin"
export const itemCount = 5
# Dashboard
{isLoggedIn ? (
<div className="welcome">
<h2>Welcome back!</h2>
<p>You have {itemCount} new notifications.</p>
</div>
) : (
<div className="login-prompt">
<h2>Please sign in</h2>
<a href="/login">Go to login page</a>
</div>
)}
{/* Nested ternaries for multiple conditions */}
<span className="badge">
{userRole === 'admin' ? '🔑 Administrator' :
userRole === 'editor' ? '✏️ Editor' :
userRole === 'viewer' ? '👁️ Viewer' :
'👤 Guest'}
</span>
Logical AND (&&) Operator
Use && when you only want to render something when a condition is true — no else needed:
export const showBanner = true
export const hasErrors = false
export const warnings = ['Deprecated API', 'Missing description']
export const user = { name: "Alex", isPremium: true, avatar: "/alex.jpg" }
# Page Content
{showBanner && (
<div className="announcement-banner">
🎉 New version released! Check out what's new.
</div>
)}
{hasErrors && <Alert type="error">Please fix the errors below.</Alert>}
{warnings.length > 0 && (
<div className="warnings">
<h3>⚠️ Warnings ({warnings.length})</h3>
<ul>
{warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
</div>
)}
{user.isPremium && <Badge>Premium Member</Badge>}
{user.avatar && <img src={user.avatar} alt={user.name} />}
Pitfall: Watch out for && with falsy values like 0. The expression {count && <Text />} renders 0 on screen when count is 0. Use {count > 0 && <Text />} instead.
Conditional Rendering with Components
Here's a tab switcher — a real pattern you'd use for "show install instructions for npm/yarn/pnpm":
import { useState } from 'react'
export const TabView = ({ tabs }) => {
const [activeTab, setActiveTab] = useState(0)
return (
<div className="tab-view">
<div className="tab-headers">
{tabs.map((tab, index) => (
<button
key={index}
className={index === activeTab ? 'active' : ''}
onClick={() => setActiveTab(index)}
>
{tab.label}
</button>
))}
</div>
<div className="tab-content">
{tabs[activeTab].content}
</div>
</div>
)
}
# Installation Guide
<TabView tabs={[
{ label: "npm", content: <code>npm install my-package</code> },
{ label: "yarn", content: <code>yarn add my-package</code> },
{ label: "pnpm", content: <code>pnpm add my-package</code> }
]} />
Mapping Over Arrays
Turning arrays into rendered lists. You'll do this constantly.
Basic Mapping
export const technologies = ['React', 'TypeScript', 'MDX', 'Tailwind CSS', 'Next.js']
export const teamMembers = [
{ id: 1, name: "Alice Johnson", role: "Engineering Lead", avatar: "/team/alice.jpg" },
{ id: 2, name: "Bob Smith", role: "Senior Developer", avatar: "/team/bob.jpg" },
{ id: 3, name: "Carol Williams", role: "UX Designer", avatar: "/team/carol.jpg" },
{ id: 4, name: "Dave Brown", role: "DevOps Engineer", avatar: "/team/dave.jpg" }
]
# Tech Stack
<ul className="tech-list">
{technologies.map((tech, index) => (
<li key={index} className="tech-item">
{tech}
</li>
))}
</ul>
# Our Team
<div className="team-grid">
{teamMembers.map(member => (
<div key={member.id} className="team-card">
<img src={member.avatar} alt={member.name} />
<h3>{member.name}</h3>
<p>{member.role}</p>
</div>
))}
</div>
You can filter, color-code, and transform data on the fly:
export const apiEndpoints = [
{ method: 'GET', path: '/users', description: 'List all users', auth: true },
{ method: 'POST', path: '/users', description: 'Create a user', auth: true },
{ method: 'GET', path: '/users/:id', description: 'Get user by ID', auth: true },
{ method: 'DELETE', path: '/users/:id', description: 'Delete a user', auth: true },
{ method: 'GET', path: '/health', description: 'Health check', auth: false }
]
export const methodColors = {
GET: '#22c55e',
POST: '#3b82f6',
PUT: '#f59e0b',
DELETE: '#ef4444',
PATCH: '#8b5cf6'
}
# API Endpoints
<table className="api-table">
<thead>
<tr>
<th>Method</th>
<th>Path</th>
<th>Description</th>
<th>Auth</th>
</tr>
</thead>
<tbody>
{apiEndpoints.map((endpoint, i) => (
<tr key={i}>
<td>
<span style={{ color: methodColors[endpoint.method], fontWeight: 'bold' }}>
{endpoint.method}
</span>
</td>
<td><code>{endpoint.path}</code></td>
<td>{endpoint.description}</td>
<td>{endpoint.auth ? '🔒' : '🌐'}</td>
</tr>
))}
</tbody>
</table>
{/* Filtered view - only authenticated endpoints */}
### Authenticated Endpoints
{apiEndpoints
.filter(e => e.auth)
.map((endpoint, i) => (
<p key={i}>
<strong>{endpoint.method}</strong> {endpoint.path} — {endpoint.description}
</p>
))
}
Grouping and Reducing
Group items by category using reduce, then render each group:
export const posts = [
{ title: "Intro to MDX", category: "tutorials", date: "2024-03" },
{ title: "Advanced Hooks", category: "react", date: "2024-03" },
{ title: "MDX Plugins", category: "tutorials", date: "2024-02" },
{ title: "State Patterns", category: "react", date: "2024-02" },
{ title: "Deploy Guide", category: "devops", date: "2024-01" }
]
export const groupedPosts = posts.reduce((groups, post) => {
const category = post.category
if (!groups[category]) groups[category] = []
groups[category].push(post)
return groups
}, {})
# Blog Archive
{Object.entries(groupedPosts).map(([category, categoryPosts]) => (
<div key={category}>
<h3>{category.charAt(0).toUpperCase() + category.slice(1)}</h3>
<ul>
{categoryPosts.map((post, i) => (
<li key={i}>{post.title} <small>({post.date})</small></li>
))}
</ul>
</div>
))}
Dynamic Styling
Styles that change based on data or state:
Inline Dynamic Styles
export const progressData = [
{ label: "HTML/CSS", level: 95 },
{ label: "JavaScript", level: 88 },
{ label: "React", level: 82 },
{ label: "TypeScript", level: 75 },
{ label: "Node.js", level: 70 }
]
# Skills
{progressData.map(skill => (
<div key={skill.label} style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>{skill.label}</span>
<span>{skill.level}%</span>
</div>
<div style={{
width: '100%',
height: '8px',
backgroundColor: '#e5e7eb',
borderRadius: '4px',
overflow: 'hidden'
}}>
<div style={{
width: `${skill.level}%`,
height: '100%',
backgroundColor: skill.level > 80 ? '#22c55e' : skill.level > 60 ? '#f59e0b' : '#ef4444',
borderRadius: '4px',
transition: 'width 0.5s ease'
}} />
</div>
</div>
))}
Conditional Class Names
Build class strings dynamically based on state:
import { useState } from 'react'
export const ThemeSwitcher = () => {
const [theme, setTheme] = useState('light')
const [fontSize, setFontSize] = useState('medium')
const containerClass = [
'content-wrapper',
`theme-${theme}`,
`font-${fontSize}`
].join(' ')
return (
<div className={containerClass}>
<div className="controls">
<button
className={theme === 'light' ? 'btn active' : 'btn'}
onClick={() => setTheme('light')}
>
☀️ Light
</button>
<button
className={theme === 'dark' ? 'btn active' : 'btn'}
onClick={() => setTheme('dark')}
>
🌙 Dark
</button>
</div>
<div className="font-controls">
{['small', 'medium', 'large'].map(size => (
<button
key={size}
className={fontSize === size ? 'active' : ''}
onClick={() => setFontSize(size)}
>
{size}
</button>
))}
</div>
<p>This text responds to theme and font size settings!</p>
</div>
)
}
<ThemeSwitcher />
State-Driven Content
Components that change what they show based on user interaction:
Interactive Quiz
import { useState } from 'react'
export const Quiz = ({ questions }) => {
const [currentQ, setCurrentQ] = useState(0)
const [score, setScore] = useState(0)
const [showResult, setShowResult] = useState(false)
const [selectedAnswer, setSelectedAnswer] = useState(null)
const handleAnswer = (answerIndex) => {
setSelectedAnswer(answerIndex)
if (answerIndex === questions[currentQ].correct) {
setScore(score + 1)
}
setTimeout(() => {
if (currentQ < questions.length - 1) {
setCurrentQ(currentQ + 1)
setSelectedAnswer(null)
} else {
setShowResult(true)
}
}, 1000)
}
if (showResult) {
return (
<div className="quiz-result">
<h3>🎉 Quiz Complete!</h3>
<p>Score: {score} / {questions.length}</p>
<p>{score === questions.length ? 'Perfect!' : score > questions.length / 2 ? 'Good job!' : 'Keep practicing!'}</p>
<button onClick={() => { setCurrentQ(0); setScore(0); setShowResult(false); setSelectedAnswer(null) }}>
Retry
</button>
</div>
)
}
const question = questions[currentQ]
return (
<div className="quiz">
<p className="progress">Question {currentQ + 1} of {questions.length}</p>
<h3>{question.text}</h3>
<div className="answers">
{question.options.map((option, i) => (
<button
key={i}
onClick={() => handleAnswer(i)}
disabled={selectedAnswer !== null}
className={
selectedAnswer === null ? 'answer-btn' :
i === question.correct ? 'answer-btn correct' :
i === selectedAnswer ? 'answer-btn wrong' : 'answer-btn'
}
>
{option}
</button>
))}
</div>
</div>
)
}
# Test Your Knowledge
<Quiz questions={[
{
text: "What does JSX stand for?",
options: ["JavaScript XML", "Java Syntax Extension", "JSON XML", "JavaScript Extension"],
correct: 0
},
{
text: "Which attribute replaces 'class' in JSX?",
options: ["cssClass", "className", "htmlClass", "styleClass"],
correct: 1
},
{
text: "How do you embed expressions in JSX?",
options: ["{{ expr }}", "{% expr %}", "{ expr }", "${ expr }"],
correct: 2
}
]} />
Expandable FAQ
import { useState } from 'react'
export const FAQ = ({ items }) => {
const [openIndex, setOpenIndex] = useState(null)
return (
<div className="faq-list">
{items.map((item, index) => (
<div key={index} className="faq-item">
<button
className="faq-question"
onClick={() => setOpenIndex(openIndex === index ? null : index)}
>
<span>{item.question}</span>
<span className="icon">{openIndex === index ? '−' : '+'}</span>
</button>
{openIndex === index && (
<div className="faq-answer">
{item.answer}
</div>
)}
</div>
))}
</div>
)
}
# Frequently Asked Questions
<FAQ items={[
{ question: "What is MDX?", answer: "MDX is a format that lets you use JSX in your Markdown documents." },
{ question: "Do I need React to use MDX?", answer: "Yes, MDX compiles to React components, so React is required." },
{ question: "Can I use TypeScript with MDX?", answer: "Absolutely! MDX has excellent TypeScript support." }
]} />
Fetching Data
Pull in external data and display it dynamically:
Client-Side Data Fetching
import { useState, useEffect } from 'react'
export const GitHubRepo = ({ owner, repo }) => {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetch(`https://api.github.com/repos/${owner}/${repo}`)
.then(res => {
if (!res.ok) throw new Error('Repo not found')
return res.json()
})
.then(data => {
setData(data)
setLoading(false)
})
.catch(err => {
setError(err.message)
setLoading(false)
})
}, [owner, repo])
if (loading) return <div className="skeleton">Loading repository info...</div>
if (error) return <div className="error">❌ Error: {error}</div>
return (
<div className="repo-card">
<h3>📦 {data.full_name}</h3>
<p>{data.description}</p>
<div className="repo-stats">
<span>⭐ {data.stargazers_count.toLocaleString()}</span>
<span>🍴 {data.forks_count.toLocaleString()}</span>
<span>📝 {data.language}</span>
</div>
<a href={data.html_url} target="_blank" rel="noopener noreferrer">
View on GitHub →
</a>
</div>
)
}
# Open Source Projects
<GitHubRepo owner="mdx-js" repo="mdx" />
<GitHubRepo owner="facebook" repo="react" />
Data Fetching with Loading States
Always show loading and error states. Never leave the user staring at nothing.
import { useState, useEffect } from 'react'
export const UserList = () => {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
useEffect(() => {
setLoading(true)
fetch(`https://jsonplaceholder.typicode.com/users?_page=${page}&_limit=5`)
.then(res => res.json())
.then(data => {
setUsers(data)
setLoading(false)
})
}, [page])
return (
<div>
{loading ? (
<div className="loading-grid">
{[1,2,3,4,5].map(i => <div key={i} className="skeleton-row" />)}
</div>
) : (
<ul>
{users.map(user => (
<li key={user.id}>
<strong>{user.name}</strong> — {user.email}
</li>
))}
</ul>
)}
<div className="pagination">
<button disabled={page === 1} onClick={() => setPage(p => p - 1)}>Previous</button>
<span>Page {page}</span>
<button onClick={() => setPage(p => p + 1)}>Next</button>
</div>
</div>
)
}
<UserList />
Template Patterns
Reusable patterns for common scenarios:
Responsive Data Table Template
export const DynamicTable = ({ columns, data, sortable = false }) => {
const [sortCol, setSortCol] = useState(null)
const [sortDir, setSortDir] = useState('asc')
const sortedData = sortCol ? [...data].sort((a, b) => {
const aVal = a[sortCol]
const bVal = b[sortCol]
const direction = sortDir === 'asc' ? 1 : -1
return aVal < bVal ? -direction : aVal > bVal ? direction : 0
}) : data
const handleSort = (col) => {
if (sortCol === col) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
} else {
setSortCol(col)
setSortDir('asc')
}
}
return (
<table className="dynamic-table">
<thead>
<tr>
{columns.map(col => (
<th
key={col.key}
onClick={sortable ? () => handleSort(col.key) : undefined}
style={{ cursor: sortable ? 'pointer' : 'default' }}
>
{col.label} {sortCol === col.key && (sortDir === 'asc' ? '↑' : '↓')}
</th>
))}
</tr>
</thead>
<tbody>
{sortedData.map((row, i) => (
<tr key={i}>
{columns.map(col => (
<td key={col.key}>{col.render ? col.render(row[col.key], row) : row[col.key]}</td>
))}
</tr>
))}
</tbody>
</table>
)
}
Search and Filter Template
import { useState, useMemo } from 'react'
export const SearchableList = ({ items, searchKeys = ['title'], renderItem }) => {
const [query, setQuery] = useState('')
const [category, setCategory] = useState('all')
const categories = useMemo(() => {
const cats = new Set(items.map(item => item.category).filter(Boolean))
return ['all', ...cats]
}, [items])
const filtered = useMemo(() => {
return items.filter(item => {
const matchesQuery = query === '' || searchKeys.some(key =>
String(item[key]).toLowerCase().includes(query.toLowerCase())
)
const matchesCategory = category === 'all' || item.category === category
return matchesQuery && matchesCategory
})
}, [items, query, category, searchKeys])
return (
<div className="searchable-list">
<div className="filters">
<input
type="search"
placeholder="Search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<p className="result-count">{filtered.length} results</p>
<div className="results">
{filtered.length === 0 ? (
<p className="no-results">No items found matching your criteria.</p>
) : (
filtered.map((item, i) => renderItem ? renderItem(item, i) : (
<div key={i} className="list-item">{item.title}</div>
))
)}
</div>
</div>
)
}
Interactive Examples
Live Counter with Multiple Controls
import { useState, useCallback } from 'react'
export const InteractiveCounter = () => {
const [count, setCount] = useState(0)
const [step, setStep] = useState(1)
const [history, setHistory] = useState([])
const updateCount = useCallback((newCount) => {
setHistory(prev => [...prev.slice(-9), { value: newCount, time: new Date().toLocaleTimeString() }])
setCount(newCount)
}, [])
return (
<div className="interactive-demo" style={{ padding: '1.5rem', border: '1px solid #e5e7eb', borderRadius: '8px' }}>
<h3 style={{ marginTop: 0 }}>Interactive Counter</h3>
<div style={{ fontSize: '3rem', textAlign: 'center', margin: '1rem 0' }}>
{count}
</div>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'center', marginBottom: '1rem' }}>
<button onClick={() => updateCount(count - step)}>− {step}</button>
<button onClick={() => updateCount(0)}>Reset</button>
<button onClick={() => updateCount(count + step)}>+ {step}</button>
</div>
<div style={{ textAlign: 'center', marginBottom: '1rem' }}>
<label>Step size: </label>
<input
type="range"
min="1"
max="10"
value={step}
onChange={(e) => setStep(Number(e.target.value))}
/>
<span> {step}</span>
</div>
{history.length > 0 && (
<details>
<summary>History ({history.length} changes)</summary>
<ul style={{ fontSize: '0.875rem' }}>
{history.map((entry, i) => (
<li key={i}>{entry.time}: {entry.value}</li>
))}
</ul>
</details>
)}
</div>
)
}
# Try It Out
<InteractiveCounter />
Color Picker with Preview
import { useState } from 'react'
export const ColorMixer = () => {
const [red, setRed] = useState(100)
const [green, setGreen] = useState(150)
const [blue, setBlue] = useState(200)
const color = `rgb(${red}, ${green}, ${blue})`
const hex = '#' + [red, green, blue].map(v => v.toString(16).padStart(2, '0')).join('')
return (
<div className="color-mixer">
<div style={{
width: '100%',
height: '100px',
backgroundColor: color,
borderRadius: '8px',
marginBottom: '1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: (red + green + blue) / 3 > 128 ? '#000' : '#fff',
fontFamily: 'monospace',
fontSize: '1.2rem'
}}>
{hex}
</div>
<div>
<label>Red: {red}</label>
<input type="range" min="0" max="255" value={red} onChange={(e) => setRed(Number(e.target.value))} />
</div>
<div>
<label>Green: {green}</label>
<input type="range" min="0" max="255" value={green} onChange={(e) => setGreen(Number(e.target.value))} />
</div>
<div>
<label>Blue: {blue}</label>
<input type="range" min="0" max="255" value={blue} onChange={(e) => setBlue(Number(e.target.value))} />
</div>
<p style={{ fontFamily: 'monospace' }}>
CSS: <code>{color}</code> | Hex: <code>{hex}</code>
</p>
</div>
)
}
# Color Playground
Mix your own colors by adjusting the RGB sliders:
<ColorMixer />
Practical Tips
<div className="tips">
Tip 1: Keep dynamic logic short in MDX files. If a component passes 50 lines, move it to a separate file and import it.
Tip 2: Always show loading and error states for data fetches. Users shouldn't stare at a blank screen.
Tip 3: Use useMemo for expensive computations (filtering, sorting large arrays) so they don't recalculate on every render.
Tip 4: Prefer controlled components (state-driven inputs) for predictable behavior.
Tip 5: Test dynamic components in isolation before embedding them in MDX. Storybook or a test page works great for this.
</div>
Common Pitfalls
- Rendering
0 with && — {items.length && <List />} shows 0 when the array is empty. Use {items.length > 0 && <List />}. - Missing keys in mapped lists — Always provide unique
key props. Using the array index is fine only for static lists. - Infinite useEffect loops — Object/array dependencies in
useEffect cause re-runs. Memoize them or use primitive deps. - Fetching on every render — Put fetches inside
useEffect with proper dependency arrays to control when they fire. - Server/client mismatch — Dynamic content that differs between server render and client hydration causes React warnings. Use
useEffect for client-only logic. - Over-engineering — Not everything needs to be dynamic. If content rarely changes, static Markdown is simpler and faster.
Summary
Dynamic content is what makes MDX worth using over plain Markdown. You've got conditional rendering for show/hide logic, .map() for lists, useState for interactivity, and useEffect + fetch for live data. Combine these patterns and your MDX pages become actual applications — quizzes, dashboards, live previews, searchable lists. Just keep things simple and extract complex components to their own files.