Generate custom React hooks following established patterns. Use when creating reusable logic, timers, debouncing, API calls, or any stateful logic that needs to be shared across components.
Generate React hooks following established patterns.
src/hooks/
├── index.ts # Hook exports
├── useTimeout.ts # Timer hook
├── useInterval.ts # Recurring timer hook
├── useRateApp.ts # App review hook
├── useAppState.ts # App state detection
├── useDebounce.ts # Debounce hook
├── useThrottle.ts # Throttle hook
├── usePrevious.ts # Previous value hook
├── useToggle.ts # Toggle state hook
├── useKeyboard.ts # Keyboard detection
├── useColorScheme.ts # Color scheme detection
└── useThemeColor.ts # Theme color utilities
Execute a callback after a delay when start condition is true.
// src/hooks/useTimeout.ts
import { useEffect } from 'react';
export const useTimeout = (callback: () => void, delay: number, start: boolean) => {
useEffect(() => {
if (!start) return;
const timer = setTimeout(() => {
callback();
}, delay);
return () => {
clearTimeout(timer);
};
}, [callback, delay, start]);
};
Basic Usage
import { useTimeout } from '@/hooks';
const MyComponent = () => {
const [showMessage, setShowMessage] = useState(false);
useTimeout(
() => setShowMessage(true),
3000, // 3 seconds
true, // start immediately
);
return showMessage ? <Text>Hello!</Text> : null;
};
Conditional Start
const RateAppPrompt = () => {
const { hasSeenPrompt } = useAppSettings();
const { requestReview } = useRateApp();
useTimeout(
requestReview,
10 * 60 * 1000, // 10 minutes
!hasSeenPrompt, // only if not seen before
);
return null;
};
Cancel on Condition Change
const AutoSave = () => {
const [hasChanges, setHasChanges] = useState(false);
const { save } = useSaveData();
useTimeout(
() => {
save();
setHasChanges(false);
},
5000,
hasChanges, // restart timer on each change
);
return null;
};
Execute a callback repeatedly at specified intervals.
// src/hooks/useInterval.ts
import { useEffect, useRef } from 'react';
export const useInterval = (callback: () => void, delay: number | null, immediate = false) => {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
if (immediate) {
savedCallback.current();
}
const id = setInterval(() => {
savedCallback.current();
}, delay);
return () => clearInterval(id);
}, [delay, immediate]);
};
Polling API
const DataPoller = () => {
const { fetchData } = useDataStore();
useInterval(
fetchData,
30000, // 30 seconds
true, // fetch immediately on mount
);
return null;
};
Countdown Timer
const CountdownTimer = () => {
const [seconds, setSeconds] = useState(60);
useInterval(
() => {
setSeconds(s => (s > 0 ? s - 1 : 0));
},
1000, // 1 second
false,
);
return <Text>{seconds}s remaining</Text>;
};
Pause/Resume
const AnimationLoop = () => {
const [isPaused, setIsPaused] = useState(false);
useInterval(
animate,
isPaused ? null : 16, // 60fps, null pauses
false,
);
return <Button onPress={() => setIsPaused(!isPaused)} />;
};
Request app store review with proper availability checks.
// src/hooks/useRateApp.ts
import { useCallback } from 'react';
import * as StoreReview from 'expo-store-review';
export const useRateApp = () => {
const requestReview = useCallback(async () => {
try {
const hasAction = await StoreReview.hasAction();
const isAvailable = await StoreReview.isAvailableAsync();
if (isAvailable && hasAction) {
await StoreReview.requestReview();
return true;
}
return false;
} catch (error) {
console.error('Error requesting app review:', error);
return false;
}
}, []);
return { requestReview };
};
Delayed Rate Prompt
import { useRateApp } from '@/hooks';
import { useAppSettingsStore } from '@/store';
const AppInit = () => {
const { requestReview } = useRateApp();
const { hasSeenRateAppPrompt, setHasSeenRateAppPrompt } = useAppSettingsStore();
useTimeout(
async () => {
const success = await requestReview();
if (success) {
setHasSeenRateAppPrompt(true);
}
},
10 * 60 * 1000, // 10 minutes after app open
!hasSeenRateAppPrompt,
);
return null;
};
After Positive Action
const SuccessScreen = () => {
const { requestReview } = useRateApp();
useEffect(() => {
// Request review after successful action
requestReview();
}, []);
return <Text>Success! 🎉</Text>;
};
Detect when app goes to background/foreground.
// src/hooks/useAppState.ts
import { useEffect, useRef } from 'react';
import { AppState, AppStateStatus } from 'react-native';
export const useAppState = (onForeground?: () => void, onBackground?: () => void) => {
const appState = useRef(AppState.currentState);
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
onForeground?.();
}
if (appState.current === 'active' && nextAppState.match(/inactive|background/)) {
onBackground?.();
}
appState.current = nextAppState;
});
return () => {
subscription.remove();
};
}, [onForeground, onBackground]);
};
Refresh Data on Foreground
const DataManager = () => {
const { refreshData } = useDataStore();
useAppState(
() => {
console.log('App came to foreground');
refreshData();
},
() => {
console.log('App went to background');
},
);
return null;
};
Lock App on Background
const SecurityManager = () => {
const { setIsAuthenticated } = useBiometricAuthStore();
useAppState(
undefined, // no foreground action
() => {
setIsAuthenticated(false); // lock on background
},
);
return null;
};
Pause/Resume Timer
const TimerScreen = () => {
const [isPaused, setIsPaused] = useState(false);
useAppState(
() => setIsPaused(false), // resume on foreground
() => setIsPaused(true), // pause on background
);
return <Timer isPaused={isPaused} />;
};
Debounce a value to reduce update frequency.
// src/hooks/useDebounce.ts
import { useState, useEffect } from 'react';
export const useDebounce = <T,>(value: T, delay: number): T => {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
};
Search Input
import { useDebounce } from '@/hooks';
const SearchScreen = () => {
const [searchText, setSearchText] = useState('');
const debouncedSearch = useDebounce(searchText, 500);
useEffect(() => {
if (debouncedSearch) {
// Only search after 500ms of no typing
performSearch(debouncedSearch);
}
}, [debouncedSearch]);
return <Input value={searchText} onChangeText={setSearchText} placeholder="Search..." />;
};
API Call Optimization
const FilteredList = () => {
const [filters, setFilters] = useState({ category: '', minPrice: 0 });
const debouncedFilters = useDebounce(filters, 300);
useEffect(() => {
fetchFilteredData(debouncedFilters);
}, [debouncedFilters]);
return <FilterInputs onChange={setFilters} />;
};
Form Validation
const EmailInput = () => {
const [email, setEmail] = useState('');
const debouncedEmail = useDebounce(email, 500);
useEffect(() => {
if (debouncedEmail) {
validateEmail(debouncedEmail);
}
}, [debouncedEmail]);
return <Input value={email} onChangeText={setEmail} />;
};
Throttle a callback to limit execution frequency.
// src/hooks/useThrottle.ts
import { useRef, useCallback } from 'react';
export const useThrottle = <T extends (...args: any[]) => any>(callback: T, delay: number): T => {
const lastRan = useRef(Date.now());
return useCallback(
((...args) => {
const now = Date.now();
if (now - lastRan.current >= delay) {
callback(...args);
lastRan.current = now;
}
}) as T,
[callback, delay],
);
};
Scroll Event
const InfiniteList = () => {
const loadMore = () => {
console.log('Loading more...');
};
const throttledLoadMore = useThrottle(loadMore, 1000);
return (
<FlatList
data={items}
onEndReached={throttledLoadMore} // Max once per second
onEndReachedThreshold={0.5}
/>
);
};
Button Press
const SubmitButton = () => {
const handleSubmit = () => {
submitForm();
};
const throttledSubmit = useThrottle(handleSubmit, 2000);
return (
<Button
title="Submit"
onPress={throttledSubmit} // Max once per 2 seconds
/>
);
};
Analytics Events
const AnalyticsTracker = () => {
const trackEvent = (event: string) => {
analytics.logEvent(event);
};
const throttledTrack = useThrottle(trackEvent, 5000);
return <Button onPress={() => throttledTrack('button_click')} />;
};
Access the previous value from the last render.
// src/hooks/usePrevious.ts
import { useRef, useEffect } from 'react';
export const usePrevious = <T,>(value: T): T | undefined => {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
};
Compare Values
import { usePrevious } from '@/hooks';
const CounterScreen = () => {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<View>
<Text>Current: {count}</Text>
<Text>Previous: {prevCount ?? 'N/A'}</Text>
<Button onPress={() => setCount(c => c + 1)} />
</View>
);
};
Conditional Effects
const UserProfile = ({ userId }: { userId: string }) => {
const prevUserId = usePrevious(userId);
useEffect(() => {
if (prevUserId && prevUserId !== userId) {
console.log(`User changed from ${prevUserId} to ${userId}`);
fetchUserData(userId);
}
}, [userId, prevUserId]);
return <ProfileView userId={userId} />;
};
Animation Triggers
const AnimatedCounter = () => {
const [value, setValue] = useState(0);
const prevValue = usePrevious(value);
const shouldAnimate = prevValue !== undefined && prevValue !== value;
return (
<Animated.View style={shouldAnimate ? animationStyle : {}}>
<Text>{value}</Text>
</Animated.View>
);
};
Simplified boolean state management.
// src/hooks/useToggle.ts
import { useState, useCallback } from 'react';
export const useToggle = (initialValue = false): [boolean, () => void, (value: boolean) => void] => {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => {
setValue(v => !v);
}, []);
return [value, toggle, setValue];
};
Modal Toggle
import { useToggle } from '@/hooks';
const MyScreen = () => {
const [isModalOpen, toggleModal, setModalOpen] = useToggle();
return (
<>
<Button title="Open" onPress={toggleModal} />
<Modal visible={isModalOpen} onClose={toggleModal}>
<Text>Modal Content</Text>
</Modal>
</>
);
};
Visibility Toggle
const PasswordInput = () => {
const [isVisible, toggleVisibility] = useToggle(false);
return (
<Input
secureTextEntry={!isVisible}
rightIcon={<TouchableOpacity onPress={toggleVisibility}>{isVisible ? <Eye /> : <EyeOff />}</TouchableOpacity>}
/>
);
};
Accordion
const AccordionItem = ({ title, children }) => {
const [isExpanded, toggleExpanded] = useToggle(false);
return (
<View>
<TouchableOpacity onPress={toggleExpanded}>
<Text>{title}</Text>
{isExpanded ? <ChevronUp /> : <ChevronDown />}
</TouchableOpacity>
{isExpanded && <View>{children}</View>}
</View>
);
};
Detect system color scheme with web variant support.
// src/hooks/useColorScheme.ts
import { useColorScheme as useRNColorScheme } from 'react-native';
export function useColorScheme() {
return useRNColorScheme() ?? 'light';
}
// src/hooks/useColorScheme.web.ts
import { useEffect, useState } from 'react';
export function useColorScheme() {
const [colorScheme, setColorScheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
setColorScheme(mediaQuery.matches ? 'dark' : 'light');
const listener = (e: MediaQueryListEvent) => {
setColorScheme(e.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', listener);
return () => mediaQuery.removeEventListener('change', listener);
}, []);
return colorScheme;
}
Theme Provider
import { useColorScheme } from '@/hooks/useColorScheme';
import { ThemeProvider, DarkTheme, DefaultTheme } from '@react-navigation/native';
export default function RootLayout() {
const colorScheme = useColorScheme();
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack />
</ThemeProvider>
);
}
.web.ts variant on webDetect keyboard visibility and height.
// src/hooks/useKeyboard.ts
import { useEffect, useState } from 'react';
import { Keyboard, KeyboardEvent } from 'react-native';
export const useKeyboard = () => {
const [isKeyboardVisible, setKeyboardVisible] = useState(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
useEffect(() => {
const showListener = Keyboard.addListener('keyboardDidShow', (e: KeyboardEvent) => {
setKeyboardVisible(true);
setKeyboardHeight(e.endCoordinates.height);
});
const hideListener = Keyboard.addListener('keyboardDidHide', () => {
setKeyboardVisible(false);
setKeyboardHeight(0);
});
return () => {
showListener.remove();
hideListener.remove();
};
}, []);
return { isKeyboardVisible, keyboardHeight };
};
Adjust Layout
import { useKeyboard } from '@/hooks';
const ChatScreen = () => {
const { keyboardHeight } = useKeyboard();
return (
<View style={{ marginBottom: keyboardHeight }}>
<MessageList />
<MessageInput />
</View>
);
};
Conditional Rendering
const FormScreen = () => {
const { isKeyboardVisible } = useKeyboard();
return (
<View>
<Form />
{!isKeyboardVisible && <FloatingButton />}
</View>
);
};
Dismiss Keyboard
const SearchScreen = () => {
const { isKeyboardVisible } = useKeyboard();
return (
<TouchableWithoutFeedback onPress={() => isKeyboardVisible && Keyboard.dismiss()}>
<View>
<SearchInput />
</View>
</TouchableWithoutFeedback>
);
};
// src/hooks/index.ts
// Timer hooks
export { useTimeout } from './useTimeout';
export { useInterval } from './useInterval';
// App integration
export { useRateApp } from './useRateApp';
export { useAppState } from './useAppState';
// Value hooks
export { useDebounce } from './useDebounce';
export { useThrottle } from './useThrottle';
export { usePrevious } from './usePrevious';
export { useToggle } from './useToggle';
// Platform hooks
export { useColorScheme } from './useColorScheme';
export { useKeyboard } from './useKeyboard';
// Theme hooks (if using)
export { useThemeColor } from './useThemeColor';
// src/hooks/__tests__/useDebounce.test.ts
import { renderHook, waitFor } from '@testing-library/react-native';
import { useDebounce } from '../useDebounce';
describe('useDebounce', () => {
it('should debounce value', async () => {
const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
initialProps: { value: 'initial', delay: 500 },
});
expect(result.current).toBe('initial');
rerender({ value: 'updated', delay: 500 });
expect(result.current).toBe('initial'); // Still old value
await waitFor(() => expect(result.current).toBe('updated'), {
timeout: 600,
});
});
});
// src/hooks/__tests__/useTimeout.test.ts
import { renderHook } from '@testing-library/react-native';
import { useTimeout } from '../useTimeout';
describe('useTimeout', () => {
jest.useFakeTimers();
it('should call callback after delay', () => {
const callback = jest.fn();
renderHook(() => useTimeout(callback, 1000, true));
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledTimes(1);
});
it('should not call callback when start is false', () => {
const callback = jest.fn();
renderHook(() => useTimeout(callback, 1000, false));
jest.advanceTimersByTime(1000);
expect(callback).not.toHaveBeenCalled();
});
});
Always include all dependencies in useEffect/useCallback:
// ❌ Bad - missing dependency
useEffect(() => {
fetchData(userId);
}, []);
// ✅ Good - all dependencies listed
useEffect(() => {
fetchData(userId);
}, [userId]);
Wrap callbacks in useCallback to prevent re-renders:
// ❌ Bad - creates new function on every render
useTimeout(() => doSomething(), 1000, true);
// ✅ Good - memoized callback
const memoizedCallback = useCallback(() => doSomething(), []);
useTimeout(memoizedCallback, 1000, true);
Always cleanup subscriptions and timers:
useEffect(() => {
const subscription = listener.subscribe();
return () => {
subscription.unsubscribe(); // Cleanup
};
}, []);
Use refs when you need the latest value without re-running effects:
const useInterval = (callback, delay) => {
const savedCallback = useRef(callback);
// Update ref on each render
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Use ref in effect (no callback dependency)
useEffect(() => {
const id = setInterval(() => {
savedCallback.current();
}, delay);
return () => clearInterval(id);
}, [delay]);
};
src/hooks/useXxx.tssrc/hooks/index.ts