Jotai State Management
Open Tarteel uses Jotai for atomic, bottom-up state management with automatic localStorage persistence.
Why Jotai?
Atomic
Each piece of state is an independent atom that can be used anywhere in the component tree without prop drilling.
TypeScript-First
Full TypeScript support with type inference for atoms and derived state.
Minimal Boilerplate
No reducers, actions, or providers needed. Just atoms and hooks.
Persistence
Built-in localStorage integration with SSR-safe hydration.
Core Atoms
All atoms are defined in src/jotai/atom.ts with localStorage persistence:
import { Reciter, Riwaya } from '@/types';
import { createAtomWithStorage } from './create-atom-with-storage';
// Favorite reciters list
export const favoriteRecitersAtom = createAtomWithStorage<string[]>(
'favorite-reciter',
[]
);
// Selected Quranic recitation style (Hafs, Warsh, etc.)
export const selectedRiwayaAtom = createAtomWithStorage<Riwaya | 'all'>(
'selected-riwaya',
'all'
);
// UI state atoms
export const hideUnderConstructionAtom = createAtomWithStorage<boolean>(
'hide-under-construction',
false
);
export const fullscreenAtom = createAtomWithStorage<boolean>(
'fullscreen',
false
);
export const showVisualizerAtom = createAtomWithStorage<boolean>(
'show-visualizer',
true
);
// Locale for internationalization
export const localeAtom = createAtomWithStorage<'ar' | 'en'>('locale', 'ar');
// Audio player state
export const currentTimeAtom = createAtomWithStorage<number>('current-time', 0);
export const playbackSpeedAtom = createAtomWithStorage<number>(
'playback-speed-value',
1
);
export type PlaybackMode = 'off' | 'shuffle' | 'repeat-one';
export const playbackModeAtom = createAtomWithStorage<PlaybackMode>(
'playback-mode',
'off'
);
export const volumeAtom = createAtomWithStorage<number>('volume-value', 1);
// Reciter selection
export const recitersSortAtom = createAtomWithStorage<
'popular' | 'alphabetical' | 'views'
>('reciters-sort-atom', 'alphabetical');
export const selectedReciterAtom = createAtomWithStorage<Reciter | null>(
'selected-reciter',
null
);
Atom Categories
User Preferences
UI State
Audio Player
Array of reciter IDs marked as favorites
Selected Quranic recitation style (Hafs, Warsh, Qalun, etc.)
Application language (Arabic or English)
recitersSortAtom
'popular' | 'alphabetical' | 'views'
Sorting preference for reciter list
Audio visualizer visibility
hideUnderConstructionAtom
Hide under-construction banner
Currently selected reciter with moshaf details
Current playback position in seconds
Volume level (0.0 to 1.0)
Playback speed multiplier (0.5x to 2.0x)
Playback mode: βoffβ, βshuffleβ, or βrepeat-oneβ
Storage Implementation
Custom Storage Adapter
import { atomWithStorage } from 'jotai/utils';
import { createStorage } from '@/utils/storage/create-storage';
export function createAtomWithStorage<T>(key: string, initialValue: T) {
return atomWithStorage<T>(key, initialValue, createStorage<T>(), {
getOnInit: true, // Load from storage on initialization
});
}
The storage adapter is SSR-safe by checking typeof window === 'undefined' before accessing localStorage.
Usage Patterns
Reading Atom Values
'use client';
import { useAtomValue } from 'jotai';
import { localeAtom, selectedReciterAtom } from '@/jotai/atom';
export function ExampleComponent() {
// Read-only hook (doesn't trigger re-render on write)
const locale = useAtomValue(localeAtom);
const reciter = useAtomValue(selectedReciterAtom);
return (
<div>
<p>Language: {locale}</p>
<p>Reciter: {reciter?.name ?? 'None selected'}</p>
</div>
);
}
Updating Atom Values
'use client';
import { useSetAtom } from 'jotai';
import { volumeAtom, playbackSpeedAtom } from '@/jotai/atom';
export function VolumeControl() {
// Write-only hook (doesn't cause re-render)
const setVolume = useSetAtom(volumeAtom);
const setSpeed = useSetAtom(playbackSpeedAtom);
return (
<div>
<input
type="range"
min="0"
max="1"
step="0.1"
onChange={(e) => setVolume(parseFloat(e.target.value))}
/>
<button onClick={() => setSpeed(1.5)}>1.5x Speed</button>
</div>
);
}
Reading and Writing
'use client';
import { useAtom } from 'jotai';
import { fullscreenAtom } from '@/jotai/atom';
import { useEffect } from 'react';
export function FullscreenToggle() {
// Read and write hook
const [isFullscreen, setIsFullscreen] = useAtom(fullscreenAtom);
// Sync with browser fullscreen API
useEffect(() => {
function onFullscreenChange() {
const isFull = !!document.fullscreenElement;
setIsFullscreen(isFull);
}
document.addEventListener('fullscreenchange', onFullscreenChange);
return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
}, [setIsFullscreen]);
const toggleFullscreen = async () => {
if (!isFullscreen) {
await document.documentElement.requestFullscreen();
} else {
await document.exitFullscreen();
}
};
return (
<button onClick={toggleFullscreen}>
{isFullscreen ? 'Exit' : 'Enter'} Fullscreen
</button>
);
}
Derived Atoms
Show Create computed state from multiple atoms
src/jotai/derived-atoms.ts
import { atom } from 'jotai';
import { selectedReciterAtom, favoriteRecitersAtom } from './atom';
// Derived atom: check if current reciter is favorited
export const isCurrentReciterFavoriteAtom = atom((get) => {
const reciter = get(selectedReciterAtom);
const favorites = get(favoriteRecitersAtom);
if (!reciter) return false;
return favorites.includes(reciter.id.toString());
});
// Write-only derived atom: toggle favorite
export const toggleFavoriteAtom = atom(
null, // no read function
(get, set) => {
const reciter = get(selectedReciterAtom);
if (!reciter) return;
const favorites = get(favoriteRecitersAtom);
const reciterId = reciter.id.toString();
if (favorites.includes(reciterId)) {
set(favoriteRecitersAtom, favorites.filter(id => id !== reciterId));
} else {
set(favoriteRecitersAtom, [...favorites, reciterId]);
}
}
);
Usage:const isFavorite = useAtomValue(isCurrentReciterFavoriteAtom);
const toggleFavorite = useSetAtom(toggleFavoriteAtom);
<button onClick={toggleFavorite}>
{isFavorite ? 'β€οΈ' : 'π€'} Favorite
</button>
Install Jotai DevTools
Already included in package.json:{
"dependencies": {
"jotai": "^2.11.1",
"jotai-devtools": "^0.12.0"
}
}
Add DevTools Provider
Wrap your app with DevTools component:import { DevTools } from 'jotai-devtools';
import 'jotai-devtools/styles.css';
export default function RootLayout({ children }) {
return (
<html>
<body>
{process.env.NODE_ENV === 'development' && <DevTools />}
{children}
</body>
</html>
);
}
Debug Atoms
Open DevTools in development mode to:
- View all atoms and their values
- Track atom updates in real-time
- Time-travel debugging
- Inspect atom dependencies
Best Practices
- Use
useAtomValue() for read-only access (better performance)
- Use
useSetAtom() for write-only access
- Use
useAtom() only when you need both read and write
// β Bad: unnecessary re-renders
const [volume, setVolume] = useAtom(volumeAtom);
return <button onClick={() => setVolume(1)}>Max</button>;
// β
Good: no re-renders
const setVolume = useSetAtom(volumeAtom);
return <button onClick={() => setVolume(1)}>Max</button>;
Use getOnInit: true to load persisted values immediately:createAtomWithStorage(key, initialValue, storage, {
getOnInit: true, // Load from localStorage on init
});
Always define TypeScript types for atoms:// β
Good: explicit type
export const volumeAtom = createAtomWithStorage<number>('volume', 1);
// β Bad: inferred type may be too loose
export const volumeAtom = createAtomWithStorage('volume', 1);
Avoid Circular Dependencies
Donβt create circular atom dependencies:// β Bad: circular dependency
const atomA = atom((get) => get(atomB) + 1);
const atomB = atom((get) => get(atomA) + 1);
// β
Good: unidirectional flow
const baseAtom = atom(0);
const derivedAtom = atom((get) => get(baseAtom) * 2);
Jotai provides a simple, type-safe, and performant state management solution with automatic persistence for Open Tarteel.