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
- Type-safe feelings: Choose from
'happy','sad','excited', or'meh' - Persistent vibes: Automatically saves to localStorage so your feeling survives refreshes (if possible)
- SSR safe: Won't crash on the server
- Simple API: Just like
useState, but with more emotion
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/