My App
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 &middot; {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>
    );
}

On this page