DEV Community

Deepika Pusala
Deepika Pusala

Posted on

Instagram Clone Frontend — Part 3: State Management (React Hooks & Browser LocalStorage)

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"        |
                                          +------------------------------+
Enter fullscreen mode Exit fullscreen mode
  1. Transient State: Handled in React memory via useState. When the user closes the tab or switches routes, this resets automatically.
  2. Persistent State: Authentication tokens and current user profiles stored securely in localStorage so 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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);
}, []);
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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();
};
Enter fullscreen mode Exit fullscreen mode

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();
  }
};
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)