Skip to main content

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

favoriteRecitersAtom
string[]
Array of reciter IDs marked as favorites
selectedRiwayaAtom
Riwaya | 'all'
Selected Quranic recitation style (Hafs, Warsh, Qalun, etc.)
localeAtom
'ar' | 'en'
Application language (Arabic or English)
recitersSortAtom
'popular' | 'alphabetical' | 'views'
Sorting preference for reciter list

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

DevTools Integration

1

Install Jotai DevTools

Already included in package.json:
{
  "dependencies": {
    "jotai": "^2.11.1",
    "jotai-devtools": "^0.12.0"
  }
}
2

Add DevTools Provider

Wrap your app with DevTools component:
src/app/layout.tsx
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>
  );
}
3

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);
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.

Build docs developers (and LLMs) love