LoopKitExamples
React Study App
Complete React study application with custom UI
Overview
This example builds a full flashcard study app using LoopKit React hooks. It includes a deck browser, study session, card editor, and stats dashboard.
App Setup
// App.tsx
import { LoopKitProvider } from '@loopkit/react';
import '@loopkit/react/styles.css';
import { DeckBrowser } from './DeckBrowser';
import { StudyView } from './StudyView';
import { StatsView } from './StatsView';
import { AddCard } from './AddCard';
import { useState } from 'react';
type View = 'decks' | 'study' | 'stats' | 'add';
export function App() {
const [view, setView] = useState<View>('decks');
const [selectedDeck, setSelectedDeck] = useState<string | null>(null);
// Custom fetcher with auth
const fetcher: typeof fetch = (input, init) =>
fetch(input, {
...init,
headers: {
...init?.headers,
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
});
return (
<LoopKitProvider apiUrl="/api/loopkit" fetcher={fetcher}>
<nav>
<button onClick={() => setView('decks')}>Decks</button>
{selectedDeck && (
<>
<button onClick={() => setView('study')}>Study</button>
<button onClick={() => setView('stats')}>Stats</button>
<button onClick={() => setView('add')}>Add Card</button>
</>
)}
</nav>
{view === 'decks' && (
<DeckBrowser
onSelect={(id) => {
setSelectedDeck(id);
setView('study');
}}
/>
)}
{view === 'study' && selectedDeck && <StudyView deckId={selectedDeck} />}
{view === 'stats' && selectedDeck && <StatsView deckId={selectedDeck} />}
{view === 'add' && selectedDeck && <AddCard deckId={selectedDeck} />}
</LoopKitProvider>
);
}Deck Browser
// DeckBrowser.tsx
import { useDeckTree } from '@loopkit/react';
import type { DeckTreeNode } from '@loopkit/react';
function DeckNode({
node,
depth,
onSelect,
}: {
node: DeckTreeNode;
depth: number;
onSelect: (id: string) => void;
}) {
return (
<div style={{ paddingLeft: `${depth * 1.5}rem` }}>
<button
onClick={() => onSelect(node.id)}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '0.5rem' }}
>
<strong>{node.name}</strong>
{node.counts && (
<span style={{ marginLeft: '1rem', color: '#666' }}>
{node.counts.new} new · {node.counts.review} review
</span>
)}
</button>
{node.children.map((child) => (
<DeckNode key={child.id} node={child} depth={depth + 1} onSelect={onSelect} />
))}
</div>
);
}
export function DeckBrowser({ onSelect }: { onSelect: (id: string) => void }) {
const { tree, loading, error } = useDeckTree();
if (loading) return <p>Loading decks...</p>;
if (error) return <p>Error: {error}</p>;
if (tree.length === 0) return <p>No decks yet. Create one to get started.</p>;
return (
<div>
<h2>Your Decks</h2>
{tree.map((root) => (
<DeckNode key={root.id} node={root} depth={0} onSelect={onSelect} />
))}
</div>
);
}Study Session
// StudyView.tsx
import { useReviewSession } from '@loopkit/react';
import type { Grade } from '@loopkit/react';
import { useEffect } from 'react';
const gradeColors: Record<Grade, string> = {
again: '#dc2626',
hard: '#ea580c',
good: '#16a34a',
easy: '#2563eb',
};
export function StudyView({ deckId }: { deckId: string }) {
const {
sessionState,
renderedContent,
nextIntervals,
progress,
canUndo,
error,
summary,
startSession,
showAnswer,
grade,
undo,
endSession,
} = useReviewSession();
useEffect(() => {
startSession(deckId);
}, [deckId, startSession]);
if (error) return <p style={{ color: 'red' }}>Error: {error}</p>;
switch (sessionState) {
case 'idle':
case 'loading':
return <p>Loading study queue...</p>;
case 'reviewing':
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>{progress.reviewed} / {progress.total}</span>
{canUndo && <button onClick={undo}>Undo</button>}
</div>
<div
style={{ padding: '2rem', border: '1px solid #ddd', borderRadius: '12px', margin: '1rem 0' }}
dangerouslySetInnerHTML={{ __html: renderedContent?.front ?? '' }}
/>
<button onClick={showAnswer} style={{ width: '100%', padding: '1rem' }}>
Show Answer
</button>
</div>
);
case 'answered':
return (
<div>
<div
style={{ padding: '2rem', border: '1px solid #ddd', borderRadius: '12px', margin: '1rem 0' }}
dangerouslySetInnerHTML={{ __html: renderedContent?.back ?? '' }}
/>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '0.5rem' }}>
{(['again', 'hard', 'good', 'easy'] as Grade[]).map((g) => (
<button
key={g}
onClick={() => grade(g)}
style={{
padding: '0.75rem',
backgroundColor: gradeColors[g],
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
}}
>
<div>{g.charAt(0).toUpperCase() + g.slice(1)}</div>
<div style={{ fontSize: '0.8rem', opacity: 0.9 }}>
{nextIntervals?.[g]}
</div>
</button>
))}
</div>
</div>
);
case 'complete':
return (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<h2>Session Complete!</h2>
{summary && (
<div>
<p>Reviewed: {summary.totalReviewed} cards</p>
<p>Correct: {summary.correctCount} ({
summary.totalReviewed > 0
? Math.round((summary.correctCount / summary.totalReviewed) * 100)
: 0
}%)</p>
<p>New cards studied: {summary.newCardsStudied}</p>
<p>Avg time: {Math.round(summary.averageTimeMsPerCard / 1000)}s per card</p>
</div>
)}
<button onClick={endSession} style={{ marginTop: '1rem', padding: '0.75rem 2rem' }}>
Done
</button>
</div>
);
}
}Stats Dashboard
// StatsView.tsx
import { useStats, useDeck } from '@loopkit/react';
export function StatsView({ deckId }: { deckId: string }) {
const { deck } = useDeck(deckId);
const { stats, loading, error } = useStats(deckId);
if (loading) return <p>Loading stats...</p>;
if (error) return <p>Error: {error}</p>;
if (!stats) return null;
return (
<div>
<h2>{deck?.name} — Statistics</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '1rem' }}>
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '8px' }}>
<h3>Card Breakdown</h3>
<p>New: {stats.breakdown.new}</p>
<p>Learning: {stats.breakdown.learning}</p>
<p>Review: {stats.breakdown.review}</p>
<p>Relearning: {stats.breakdown.relearning}</p>
</div>
<div style={{ padding: '1rem', border: '1px solid #ddd', borderRadius: '8px' }}>
<h3>Performance</h3>
<p>Retention: {(stats.retention * 100).toFixed(1)}%</p>
<p>Study streak: {stats.streak} days</p>
<p>Average ease: {stats.averageEase.toFixed(2)}</p>
</div>
</div>
</div>
);
}Card Editor
// AddCard.tsx
import { useCardEditor, useNoteTypes } from '@loopkit/react';
import { useState } from 'react';
export function AddCard({ deckId }: { deckId: string }) {
const { create, loading, error } = useCardEditor();
const { noteTypes } = useNoteTypes();
const [front, setFront] = useState('');
const [back, setBack] = useState('');
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSuccess(false);
const basicType = noteTypes.find((t) => t.name === 'Basic');
if (!basicType) return;
await create(
basicType.id,
[
{ name: 'Front', value: front, ordinal: 0 },
{ name: 'Back', value: back, ordinal: 1 },
],
deckId,
);
setFront('');
setBack('');
setSuccess(true);
};
return (
<div>
<h2>Add Card</h2>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '1rem' }}>
<label>Front</label>
<textarea
value={front}
onChange={(e) => setFront(e.target.value)}
rows={3}
style={{ width: '100%', display: 'block' }}
required
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label>Back</label>
<textarea
value={back}
onChange={(e) => setBack(e.target.value)}
rows={3}
style={{ width: '100%', display: 'block' }}
required
/>
</div>
<button type="submit" disabled={loading}>
{loading ? 'Adding...' : 'Add Card'}
</button>
{success && <p style={{ color: 'green' }}>Card added!</p>}
{error && <p style={{ color: 'red' }}>{error}</p>}
</form>
</div>
);
}