My Cheatsheets

useHookedOnAFeeling - apps have feelings too

With a nod to Blue Swede

A simple custom React hook that manages your app's emotional state. Because every app deserves to have feelings!

import { useState, useEffect, useCallback } from 'react';

// #4: Exported for external iteration, validation, or UI generation
export const FEELINGS = ['happy', 'sad', 'excited', 'meh'] as const;
export type Feeling = typeof FEELINGS[number];

const isBrowser = typeof window !== 'undefined' && typeof localStorage !== 'undefined';

// Type guard moved to module scope (no re-creation on render)
const isFeeling = (v: unknown): v is Feeling =>
  typeof v === 'string' && FEELINGS.includes(v as Feeling);

/**
 * Configuration options for useHookedOnAFeeling
 */
export interface HookedOnAFeelingOptions {
  /** #2: Custom localStorage key (default: 'myFeeling') */
  key?: string;
  /** Optional callback when feeling changes */
  onChange?: (newFeeling: Feeling, source: 'local' | 'remote') => void;
}

/**
 * 🎵 I'm hooked on a feeling!
 * A simple hook to manage your app's emotional state with cross-tab sync.
 */
function useHookedOnAFeeling(
  initialFeeling: Feeling = 'meh',
  options: HookedOnAFeelingOptions = {}
): readonly [Feeling, (newFeeling: Feeling) => void] {
  const { key = 'myFeeling', onChange } = options;

  // Lazy initialization with SSR safety
  const [feeling, setFeeling] = useState<Feeling>(() => {
    if (!isBrowser) return initialFeeling;
    try {
      const saved = localStorage.getItem(key);
      return isFeeling(saved) ? saved : initialFeeling;
    } catch {
      return initialFeeling;
    }
  });

  // Persist to localStorage when feeling changes locally
  useEffect(() => {
    if (!isBrowser) return;
    try {
      localStorage.setItem(key, feeling);
    } catch {
      /* ignore storage errors (private mode, quota, etc.) */
    }
  }, [feeling, key]);

  // #1: Cross-tab sync via storage event
  useEffect(() => {
    if (!isBrowser) return;

    const handler = (e: StorageEvent) => {
      // Only respond to our key and valid values
      if (e.key !== key || !isFeeling(e.newValue)) return;
      
      // Update state to match remote change
      setFeeling(e.newValue);
      // Notify consumer this was a remote update
      onChange?.(e.newValue, 'remote');
    };

    window.addEventListener('storage', handler);
    return () => window.removeEventListener('storage', handler);
  }, [key, onChange]);

  // Wrapped setter to trigger onChange for local changes only
  const setFeelingWrapped = useCallback((newFeeling: Feeling) => {
    setFeeling(newFeeling);
    onChange?.(newFeeling, 'local');
  }, [onChange]);

  return [feeling, setFeelingWrapped] as const;
}

export default useHookedOnAFeeling;

Usage

import useHookedOnAFeeling from './useHookedOnAFeeling';

function MyComponent() {
  const [feeling, setFeeling] = useHookedOnAFeeling('happy');
  
  return (
    <div>
      <p>Currently feeling: {feeling}</p>
      <button onClick={() => setFeeling('excited')}>Get Excited!</button>
    </div>
  );
}

With Custom Storage Key

// Use a namespaced key to avoid collisions
const [mood, setMood] = useHookedOnAFeeling('meh', { 
  key: 'app:user:mood' 
});

With Change Callback (Analytics / Side Effects)

const [feeling, setFeeling] = useHookedOnAFeeling('happy', {
  key: 'user:emotion',
  // Note: in a real component, wrap this in useCallback to avoid
  // unnecessary effect re-runs on every render
  onChange: (newFeeling, source) => {
    console.log(`Feeling changed to ${newFeeling} via ${source}`);
    
    // Example: Track in analytics only for user-initiated changes
    if (source === 'local') {
      analytics.track('emotion_changed', { feeling: newFeeling });
    }
  }
});

Using Exported Constants (Feature #4)

import { FEELINGS, type Feeling } from './useHookedOnAFeeling';

// Generate UI dynamically
function FeelingSelector({ onSelect }: { onSelect: (f: Feeling) => void }) {
  return (
    <div className="flex gap-2">
      {FEELINGS.map(f => (
        <button 
          key={f}
          onClick={() => onSelect(f)}
          className="capitalize"
        >
          {f}
        </button>
      ))}
    </div>
  );
}

// Or validate external input
function parseFeeling(input: string): Feeling | null {
  return FEELINGS.includes(input as Feeling) ? input as Feeling : null;
}

Features

Why?

Because sometimes your app needs to express itself. Also, the name was too good to pass up.

Need more info on hooks, see React Hooks — Practical Summary.

Notes

If you need additional feeling types, server-side onChange hydration, or dev-mode storage warnings, you can extend it -- but then it might lose its charm!

If you pass an inline function as onChange, the cross-tab sync effect will re-run on every render because the function reference changes each time. To avoid this, memoize your callback with useCallback:

const handleChange = useCallback((newFeeling: Feeling, source: 'local' | 'remote') => {
  console.log(`Feeling changed to ${newFeeling} via ${source}`);
}, []);

const [feeling, setFeeling] = useHookedOnAFeeling('happy', {
  onChange: handleChange
});

Passing an unmemoized inline arrow function directly in the options object will work, but may cause unnecessary effect teardown and reattachment on every render.

Don Glover 
https://www.linkedin.com/in/don-glover/