In modern single-page applications (SPAs), state management is the brain that drives everything the user sees and does. In our Instagram clone, our frontend handles many dynamic states simultaneously:
- Authentication state: Is the user logged in? Who is the current user? Does their session persist on page reload?
- Infinite feed state: The array of loaded posts, the keyset cursor for the next batch, and pagination status.
- UI modal state: Is the login dialog, create post popup, or lightbox photo viewer currently open?
- User interactions: Following/unfollowing creators, searching handles, and deep-linking to posts (
/p/:postId).
Instead of introducing heavyweight external state libraries like Redux or MobX—which add boilerplate and overhead—we achieved a clean, robust, and lightning-fast state architecture using Native React Hooks combined with Browser localStorage.
In this tutorial, we will explore how this state machine is designed and how to build production-grade session persistence.
1. The Architectural Overview: Local vs. Persistent State
We divide state into two categories based on lifetime:
+-------------------------------------------------------------------------+ | REACT APPLICATION | +------------------------------------+------------------------------------+ | Transient State | Persistent State (In-Memory / RAM) | (Survives Page Refresh) | +-----------------------+ | +------------------------------+ | UI Modals & Dialogs | | | Session Token (`Bearer`) | | Search Query Input | | | Active User Profile Object | | PhotoViewer Active | | +---------------+--------------+ | Cursor & Feed Posts | | | +-----------+-----------+ | v | | +------------------------------+ v | | Browser LocalStorage | React useState & | | key: "ig_auth_token" | useCallback Hooks | | key: "ig_auth_user" | +------------------------------+ - Transient State: Handled in React memory via
useState. When the user closes the tab or switches routes, this resets automatically. - Persistent State: Authentication tokens and current user profiles stored securely in
localStorageso users don't have to re-login every time they refresh the page.
2. Browser LocalStorage: Secure Session Persistence
The browser's localStorage API provides a simple key-value store that persists across browser sessions. However, reading or writing to localStorage naively can throw exceptions (e.g., if cookies are disabled, storage quota is exceeded, or private browsing mode restricts access).
To ensure high reliability, we encapsulate localStorage access with dedicated helper functions wrapped in try...catch blocks inside src/utils/api.js:
// src/utils/api.js export const AUTH_TOKEN_KEY = 'ig_auth_token'; export const AUTH_USER_KEY = 'ig_auth_user'; /** * Retrieves the stored Bearer token safely. */ export function getAuthToken() { try { return localStorage.getItem(AUTH_TOKEN_KEY); } catch { return null; } } /** * Retrieves and parses the stored user profile JSON. */ export function getStoredUser() { try { const raw = localStorage.getItem(AUTH_USER_KEY); return raw ? JSON.parse(raw) : null; } catch { return null; } } /** * Persists session credentials after a successful login or registration. */ export function setAuthSession(token, user) { try { if (token) { localStorage.setItem(AUTH_TOKEN_KEY, token); } if (user) { localStorage.setItem(AUTH_USER_KEY, JSON.stringify(user)); } } catch (error) { console.error('Failed to save auth session to localStorage:', error); } } /** * Clears stored credentials upon logout or 401 session expiration. */ export function clearAuthSession() { try { localStorage.removeItem(AUTH_TOKEN_KEY); localStorage.removeItem(AUTH_USER_KEY); } catch (error) { console.error('Failed to clear auth session from localStorage:', error); } } Automatic Bearer Header Injection
With our token helper ready, our API client injects the token into all outbound network calls automatically:
export function getAuthHeaders() { const token = getAuthToken(); const headers = { 'Accept': 'application/json' }; if (token) { // Automatically attach Bearer token to authorize backend requests headers['Authorization'] = `Bearer ${token}`; } return headers; } 3. Lazy Initial State via useState
A common beginner mistake in React is initializing state from localStorage directly in the component body:
// ❌ Anti-pattern: getStoredUser() runs on EVERY single re-render! const [currentUser, setCurrentUser] = useState(getStoredUser()); Because reading from disk storage via localStorage is synchronous, running it on every re-render causes micro-stutters in UI performance.
The Solution: Lazy Initial State (Function Initialization)
By passing a callback function to useState, React executes the initial lookup only once when the component first mounts:
// ✅ Optimized: Runs exactly once during the initial mount phase! const [currentUser, setCurrentUser] = useState(() => getStoredUser()); 4. Mastering Core React Hooks in our Architecture
Let's look at how the four most important React Hooks power our application:
1. useState: Granular Component & App State
In our root App.jsx, state is organized cleanly by domain:
export function App() { // Feed & Pagination state const [posts, setPosts] = useState([]); const [cursor, setCursor] = useState(null); const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // Modal dialog states (True = open, False = closed) const [isAuthModalOpen, setIsAuthModalOpen] = useState(false); const [isCreatePostModalOpen, setIsCreatePostModalOpen] = useState(false); const [selectedPost, setSelectedPost] = useState(null); // User session state initialized from disk const [currentUser, setCurrentUser] = useState(() => getStoredUser()); // ... } 2. useEffect: Deep Linking & URL Synchronization
Instagram allows users to open posts directly via URLs like /p/102 or visit profiles via /profile/codemaster.
We use useEffect to listen to browser navigation events (Back and Forward buttons) and synchronize URL parameters with React state:
useEffect(() => { const handlePopState = () => { // Check if the current URL points to a specific post modal const postId = getPostIdFromUrl(); if (postId) { fetchPostDetailApi(postId).then(post => setSelectedPost(post)); } else { setSelectedPost(null); } // Check if the URL points to a user profile const profileHandle = getProfileHandleFromUrl(); setViewingProfile(profileHandle); }; // Subscribe to browser history popstate event window.addEventListener('popstate', handlePopState); // Cleanup listener when component unmounts return () => window.removeEventListener('popstate', handlePopState); }, []); 3. useCallback: Memoizing Performance-Critical Handlers
When passing callback functions down to child components (like onFollowToggle or onLoadMore), creating fresh function references on every render can cause child components to re-render unnecessarily.
We wrap critical handlers in useCallback so their memory reference stays stable unless their dependencies change:
const handleFollowToggle = useCallback(async (targetUserId) => { if (!currentUser) { setIsAuthModalOpen(true); return; } try { const updatedStatus = await toggleFollowApi(targetUserId); // Update local state smoothly without full reload setAllUsers((prevUsers) => prevUsers.map((user) => user.id === targetUserId ? { ...user, is_following: updatedStatus.is_following } : user ) ); } catch (err) { console.error('Follow action failed:', err); } }, [currentUser]); // Only recreate this function if currentUser changes! 5. Implementing Login and Logout Lifecycle
Here is how authentication state transitions cleanly between React memory and localStorage:
Login Handler:
const handleLoginSuccess = (authData) => { // 1. Persist to browser localStorage setAuthSession(authData.token, authData.user); // 2. Update React in-memory state setCurrentUser(authData.user); // 3. Close auth modal setIsAuthModalOpen(false); // 4. Trigger feed refresh for the logged-in user resetAndReloadFeed(); }; Logout Handler:
const handleLogout = async () => { try { // 1. Notify backend to revoke the token session await logoutApi(); } catch (err) { console.warn('Backend logout notification failed:', err); } finally { // 2. Clear credentials from localStorage clearAuthSession(); // 3. Clear user from React state setCurrentUser(null); // 4. Reset personalized feed to public view resetAndReloadFeed(); } }; Summary of Best Practices
| Pattern | Problem It Solves | Implementation in Our Project |
|---|---|---|
Encapsulated localStorage | Prevents storage crashes and JSON parsing exceptions across browsers. | Safe try...catch getters and setters in src/utils/api.js. |
Lazy useState | Avoids redundant, synchronous disk reads on every component re-render. | useState(() => getStoredUser()). |
useCallback Memoization | Prevents unwanted re-renders of lists and cards when passing functions as props. | Memoized action handlers like handleFollowToggle. |
Clean useEffect Cleanup | Prevents memory leaks and duplicate window event listeners. | Returning unbind functions: () => window.removeEventListener(...). |
In the next frontend post, we will explore Infinite Scrolling & Network Synchronization: IntersectionObserver, AbortController, and Keyset Pagination Integration!
