Observer Pattern - practical React example

TL;DR: In this article, we'll implement a real-world feature using the Observer pattern. I will walk you through the implementation step-by-step, demonstrate seamless React integration and show how this pattern transforms complex features into elegantly simple solutions. Observer pattern Observer Pattern - a simple yet powerful architectural pattern that forms the foundation of many reactive systems. At its essence, the pattern works through three key components: A publisher maintains a registry of interested parties. Multiple subscribers register themselves to receive notifications. When the publisher's state changes, it notifies all subscribers, allowing them to react accordingly. This elegant mechanism creates a one-to-many relationship where changes in one object automatically trigger updates in multiple dependent objects, all without tight coupling. To demonstrate how this pattern solves real-world problems, let's implement a practical feature. Building token rotation with UI session timer Modern web applications require sophisticated authentication mechanisms. Let's tackle a common challenge: implementing token rotation with a user-facing session timer. The Authentication Challenge Token rotation follows a standard pattern in modern auth systems: Your application receives an access token (short-lived) and a refresh token (longer lifespan). The access token is automatically attached to each API request as proof of identity. When the access token expires, API calls return a 401 Unauthorized error. After receiving 401: Exchange your refresh token for a new token pair. Automatically retry the original request with the new access token. Adding UI Complexity While token rotation alone is challenging enough, let's enhance the user experience by adding a session timer that: Displays the remaining time before the user's session expires (when the refresh token becomes invalid). Resets whenever tokens are refreshed. Updates in real-time across all components that need this information. Implementing these requirement in a traditional way would require complex prop drilling or context providers. But with the Observer pattern, we can create an elegant solution that separates the authentication logic from the UI components that consume it. Let's build this feature step-by-step. Attaching access token to each request First, let's implement automatic token attachment by registering a request interceptor with Axios. This elegantly ensures every outgoing API request includes the current authentication token: const requestSuccessInterceptor = ( config: InternalAxiosRequestConfig ): InternalAxiosRequestConfig => { const accessToken = localStorage.getItem('access-token'); if (accessToken) { const headers = AxiosHeaders.concat(config.headers, { Authorization: `Bearer ${accessToken}`, }); return { ...config, headers, }; } return config; }; axios.interceptors.request.use(requestSuccessInterceptor); Rotate tokens logic For token refresh there's some extra logic, I've got inspired from axios-auth-refresh library which helps with this problem but since this library is not maintained anymore (last update 2 years ago when writing this article) I decided to write my own logic from scratch, but be careful - this code is not battle tested so don't use it in production. I am sure you could get inspired from this and improve it for your own needs. First, we need to extend Axios's type system to prevent infinite token rotation loops. By adding a custom property to the AxiosRequestConfig interface, we can selectively skip the token rotation process for certain requests (like the token refresh call itself): declare module 'axios' { interface AxiosRequestConfig { skipTokensRotation?: boolean; } } Next, we'll define a function to handle token refresh operations. This function: Retrieves the current refresh token from storage. Sends it to our authentication endpoint. Stores the new token pair upon success. // expects Axios instance in parameter instead of importing it directly to avoid circular dependencies export const refreshTokens = async (axios: AxiosInstance) => { const storedRefreshToken = localStorage.getItem('refresh-token'); if (!storedRefreshToken) { return Promise.reject(); } const { data: { accessToken, refreshToken }, } = await axios.post( '/auth/refresh', { refreshToken: `Bearer ${storedRefreshToken}`, }, { skipTokensRotation: true } ); localStorage.setItem('access-token', accessToken); localStorage.setItem('refresh-token', refreshToken); }; Now we'll create the response interceptor that automatically detects authentication failures and triggers the token refresh cycle: axios.interceptors.response.use( (response) => response, async (error: unknown) => { if ( !isAxiosError(error) || error?.re

May 9, 2025 - 17:00
 0
Observer Pattern - practical React example

TL;DR: In this article, we'll implement a real-world feature using the Observer pattern. I will walk you through the implementation step-by-step, demonstrate seamless React integration and show how this pattern transforms complex features into elegantly simple solutions.

Observer pattern

Observer Pattern - a simple yet powerful architectural pattern that forms the foundation of many reactive systems. At its essence, the pattern works through three key components:

  1. A publisher maintains a registry of interested parties.
  2. Multiple subscribers register themselves to receive notifications.
  3. When the publisher's state changes, it notifies all subscribers, allowing them to react accordingly.

This elegant mechanism creates a one-to-many relationship where changes in one object automatically trigger updates in multiple dependent objects, all without tight coupling.
To demonstrate how this pattern solves real-world problems, let's implement a practical feature.

Building token rotation with UI session timer

Modern web applications require sophisticated authentication mechanisms. Let's tackle a common challenge: implementing token rotation with a user-facing session timer.

The Authentication Challenge

Token rotation follows a standard pattern in modern auth systems:

  • Your application receives an access token (short-lived) and a refresh token (longer lifespan). The access token is automatically attached to each API request as proof of identity.
  • When the access token expires, API calls return a 401 Unauthorized error.
  • After receiving 401:
    • Exchange your refresh token for a new token pair.
    • Automatically retry the original request with the new access token.

Adding UI Complexity

While token rotation alone is challenging enough, let's enhance the user experience by adding a session timer that:

  • Displays the remaining time before the user's session expires (when the refresh token becomes invalid).
  • Resets whenever tokens are refreshed.
  • Updates in real-time across all components that need this information.

Implementing these requirement in a traditional way would require complex prop drilling or context providers. But with the Observer pattern, we can create an elegant solution that separates the authentication logic from the UI components that consume it.
Let's build this feature step-by-step.

Attaching access token to each request

First, let's implement automatic token attachment by registering a request interceptor with Axios. This elegantly ensures every outgoing API request includes the current authentication token:

const requestSuccessInterceptor = (
  config: InternalAxiosRequestConfig
): InternalAxiosRequestConfig => {
  const accessToken = localStorage.getItem('access-token');

  if (accessToken) {
    const headers = AxiosHeaders.concat(config.headers, {
      Authorization: `Bearer ${accessToken}`,
    });

    return {
      ...config,
      headers,
    };
  }

  return config;
};

axios.interceptors.request.use(requestSuccessInterceptor);

Rotate tokens logic

For token refresh there's some extra logic, I've got inspired from axios-auth-refresh library which helps with this problem but since this library is not maintained anymore (last update 2 years ago when writing this article) I decided to write my own logic from scratch, but be careful - this code is not battle tested so don't use it in production. I am sure you could get inspired from this and improve it for your own needs.

First, we need to extend Axios's type system to prevent infinite token rotation loops. By adding a custom property to the AxiosRequestConfig interface, we can selectively skip the token rotation process for certain requests (like the token refresh call itself):

declare module 'axios' {
  interface AxiosRequestConfig {
    skipTokensRotation?: boolean;
  }
}

Next, we'll define a function to handle token refresh operations. This function:

  1. Retrieves the current refresh token from storage.
  2. Sends it to our authentication endpoint.
  3. Stores the new token pair upon success.
// expects Axios instance in parameter instead of importing it directly to avoid circular dependencies
export const refreshTokens = async (axios: AxiosInstance) => {
  const storedRefreshToken = localStorage.getItem('refresh-token');

  if (!storedRefreshToken) {
    return Promise.reject();
  }

  const {
    data: { accessToken, refreshToken },
  } = await axios.post<TokensPair>(
    '/auth/refresh',
    {
      refreshToken: `Bearer ${storedRefreshToken}`,
    },
    { skipTokensRotation: true }
  );
  localStorage.setItem('access-token', accessToken);
  localStorage.setItem('refresh-token', refreshToken);
};

Now we'll create the response interceptor that automatically detects authentication failures and triggers the token refresh cycle:

axios.interceptors.response.use(
  (response) => response,
  async (error: unknown) => {
    if (
      !isAxiosError(error) ||
      error?.response?.status !== UNAUTHORIZED_STATUS_CODE ||
      error.config?.skipTokensRotation
    ) {
      return Promise.reject(error);
    }

    try {
      await refreshTokens(axios);
    } catch {
      return Promise.reject(error);
    }
    return axios({ ...error.config, skipTokensRotation: true });
  }
);

Demo time

For demonstration purposes, I've created a simple API in NestJS with the following characteristics:

  • Refresh token expires after 10 seconds.
  • Access token expires after just 5 seconds.
  • Endpoints:
    • POST /auth/login - Issues a new token pair.
    • POST /auth/refresh - Exchanges refresh token for a new pair.
    • GET /me - Returns user details (requires valid access token).

Here's a minimal React application that demonstrates our token rotation system with an integrated session timer (I'll leave implementation details of useTimer to you):

export const App = () => {
  const timer = useTimer();
  const checkMe = async () => {
    await axios.get('/me');
  };

  const logIn = async () => {
    const { data } = await axios.post<TokensPair>('/auth/login');
    localStorage.setItem('access-token', data.accessToken);
    localStorage.setItem('refresh-token', data.refreshToken);

    const secondsUntilExpiration = getJwtSecondsUntilExpiration(
      data.refreshToken
    );
    if (secondsUntilExpiration > 0) {
      timer.start(secondsUntilExpiration);
    }
  };

  return (
    <div>
      <button onClick={logIn}>Log Inbutton>
      <button onClick={checkMe}>Check mebutton>
      {timer.state === 'running' && <div>{timer.secondsLeft}div>}
    div>
  );
};

Our current implementation has a critical flaw: the token rotation happens silently in the background through Axios interceptors, completely outside of React's lifecycle and state management system. This creates a disconnect between our authentication logic and UI components.

The problem becomes visible when considering our session timer:

  1. The timer starts correctly when a user logs in.
  2. It counts down based on the initial refresh token's expiration time.
  3. But when the access token expires and our interceptor silently refreshes both tokens, the timer continues counting down based on the old expiration time.

This means our UI will display incorrect information. The user might see that session expires in few seconds even though a new refresh token with a fresh 10-second lifespan was just obtained.

To better visualize how this works in practice, I've recorded a quick demo from our application:
Tokens rotation demo

Here's what's actually happening in this flow:

  1. Initial Login: User successfully logs in, receiving both tokens.
  2. Authentication Works: GET /me requests succeed with the valid access token.
  3. Token Expiration: After 10 seconds, both access and refresh tokens expire.
  4. Authentication Fails: GET /me fails, and the refresh attempt also fails due to the expired refresh token.
  5. User Logs In Again: A new set of valid tokens is obtained.
  6. Authentication Restored: GET /me succeeds with the fresh access token.
  7. Silent Token Rotation: After 5 seconds, the access token expires but the refresh token remains valid.
  8. Behind-the-Scenes Magic:
    • GET /me initially fails due to expired access token.
    • The interceptor silently requests new tokens via POST /auth/refresh.
    • New tokens are obtained and stored.
    • The original GET /me request is automatically retried and succeeds.

The Critical UI Flaw: Despite new tokens being obtained with fresh expiration times, our session timer continues counting down based on the original values - creating a disconnect between the actual session state and what's displayed to the user.

Fixing the problem

This is a perfect example of when the Observer pattern shines - we need a communication channel between our non-React authentication system and our React components. We need a way for the token rotation mechanism to announce: "Hey, I just refreshed the tokens!" and for interested components to react accordingly.
Let's solve this problem by implementing the Observer pattern to bridge this gap.

Now we need to create the central component of our Observer pattern: an object that maintains a registry of subscribers and notifies them whenever token state changes:

export class TokensStorage {
  private subscribers: VoidFunction[] = [];

  public subscribe(subscriber: VoidFunction) {
    this.subscribers = [...this.subscribers, subscriber];

    return () => {
      this.subscribers = this.subscribers.filter((s) => s !== subscriber);
    };
  }

  private notifySubscribers() {
    for (const subscriber of this.subscribers) {
      subscriber();
    }
  }
}

Next, we'll enhance our implementation with the Singleton pattern. This architectural choice is crucial because our token storage must serve as a single source of truth throughout the application.
This pattern complements the Observer mechanism perfectly - the singleton ensures there's only one publisher, while the Observer pattern ensures all subscribers receive notifications from this central authority.

export class TokensStorage {
+ private static instance: TokensStorage | undefined;
  private subscribers: VoidFunction[] = [];

+ private constructor() {
+   this.subscribe = this.subscribe.bind(this);
+}

+ public static getInstance(): TokensStorage {
+   if (!TokensStorage.instance) {
+     TokensStorage.instance = new TokensStorage();
+   }

+   return TokensStorage.instance;
+ }

  // ...
}

Now we'll enhance our TokensStorage class with the essential functionality it needs to manage authentication state. We'll add:

  1. Private fields to store the current token pair.
  2. Initialization logic to load existing tokens from storage.
  3. Getter methods to access tokens securely.
  4. Save methods to persist tokens and trigger notifications. The critical point here is the notification system within the save method. By calling notifySubscribers() whenever tokens change, we create the reactive mechanism that powers our entire solution. This is the exact moment where the Observer pattern creates value - any component that has registered as a subscriber will be immediately notified when new tokens are obtained.
export class TokensStorage {
  private static instance: TokensStorage | undefined;
  private subscribers: VoidFunction[] = [];
+ private accessToken: string | undefined;
+ private refreshToken: string | undefined;


  private constructor() {
   this.subscribe = this.subscribe.bind(this);
+  this.getAccessToken = this.getAccessToken.bind(this);
+  this.getRefreshToken = this.getRefreshToken.bind(this);
+  this.accessToken = localStorage.getItem(accessTokenStorageKey) ?? undefined;
+  this.refreshToken =
+    localStorage.getItem(refreshTokenStorageKey) ?? undefined;
  }

  public static getInstance(): TokensStorage {
    if (!TokensStorage.instance) {
      TokensStorage.instance = new TokensStorage();
    }

    return TokensStorage.instance;
  }

  public subscribe(subscriber: VoidFunction) {
    this.subscribers = [...this.subscribers, subscriber];

    return () => {
      this.subscribers = this.subscribers.filter((s) => s !== subscriber);
    };
  }

+ public getAccessToken(): string | undefined {
+   return this.accessToken;
+ }

+ public getRefreshToken(): string | undefined {
+   return this.refreshToken;
+ }

+ public save({ accessToken, refreshToken }: TokensPair) {
+   this.saveAccessToken(accessToken);
+   this.saveRefreshToken(refreshToken);
+   this.notifySubscribers();
+ }

+ private saveAccessToken(token: string) {
+   localStorage.setItem(accessTokenStorageKey, token);
+   this.accessToken = token;
+ }

+ private saveRefreshToken(token: string) {
+   localStorage.setItem(refreshTokenStorageKey, token);
+   this.refreshToken = token;
+ }

  private notifySubscribers() {
    for (const subscriber of this.subscribers) {
      subscriber();
    }
  }
}

You could also add more logic like clearing tokens (and notifying subscribers about it) for logout purposes but I'll leave implementation to you.

Now, we need to update our existing authentication code to use this central token storage. Instead of directly accessing localStorage throughout our application, we'll route all token operations through our singleton TokensStorage instance.

const requestSuccessInterceptor = (
  config: InternalAxiosRequestConfig
): InternalAxiosRequestConfig => {
-  const accessToken = localStorage.getItem('access-token');
+  const accessToken = TokensStorage.getInstance().getAccessToken();
// ...
export const refreshTokens = async (axios: AxiosInstance) => {
- const storedRefreshToken = localStorage.getItem('refresh-token');
+ const tokensStorage = TokensStorage.getInstance();
+ const storedRefreshToken = tokensStorage.getRefreshToken();

  if (!storedRefreshToken) {
    return Promise.reject();
  }

  const {
    data: { accessToken, refreshToken },
  } = await axios.post(
    '/auth/refresh',
    {
      refreshToken: `Bearer ${storedRefreshToken}`,
    },
    { skipTokensRotation: true }
  );
- localStorage.setItem('access-token', accessToken);
- localStorage.setItem('refresh-token', refreshToken);
+ tokensStorage.save({ accessToken, refreshToken });
};
  const logIn = async () => {
    const { data } = await axios.post('/auth/login');
-   localStorage.setItem('access-token', data.accessToken);
-   localStorage.setItem('refresh-token', data.refreshToken);
+   TokensStorage.getInstance().save({
+     accessToken: data.accessToken,
+     refreshToken: data.refreshToken,
+   });

Now comes the exciting part - connecting our Observer pattern implementation with React's rendering system. For this integration, we'll leverage React's powerful useSyncExternalStore hook, which was designed specifically for synchronizing React components with external state systems.
The useSyncExternalStore hook provides the perfect bridge between our token storage class and React components. This elegant hook subscribes to external state sources and re-renders components when those sources change.

const useRefreshToken = () => {
  const tokensStorage = TokensStorage.getInstance();
  return useSyncExternalStore(
    tokensStorage.subscribe,
    tokensStorage.getRefreshToken
  );
};

const useSessionTimer = () => {
  const refreshToken = useRefreshToken();
  const { start, ...timer } = useTimer();

  useEffect(() => {
    if (refreshToken) {
      const secondsUntilExpiration = getJwtSecondsUntilExpiration(refreshToken);
      if (secondsUntilExpiration > 0) {
        start(secondsUntilExpiration);
      }
    }
  }, [refreshToken, start]);

  if (timer.state === 'running') {
    return { state: timer.state, secondsLeft: timer.secondsLeft };
  }

  return { state: timer.state };
};

And make changes in our React code:

export const App = () => {
+ const timer = useSessionTimer();
- const timer = useTimer();
  const checkMe = async () => {
    await axios.get('/me');
  };

  const logIn = async () => {
    const { data } = await axios.post('/auth/login');
    TokensStorage.getInstance().save({
      accessToken: data.accessToken,
      refreshToken: data.refreshToken,
    });

-   const secondsUntilExpiration = getJwtSecondsUntilExpiration(
-     data.refreshToken
-   );
-   if (secondsUntilExpiration > 0) {
-     timer.start(secondsUntilExpiration);
-   }
  };

For demo purposes I also created an isolated component which shows an alert when session is about to expire:

export const SessionExpiresSoonAlert = () => {
  const timer = useSessionTimer();
  if (timer.state === 'running' && timer.secondsLeft < 5) {
    return (
      <div style={{ color: 'red' }}>
        Session is going to expire soon, take an action!
      </div>
    );
  }

  return null;
};

Here's the final result:

Image description

As you can see our timer is synced each time refresh token changes and this logic is reusable anywhere in the application.

Wrapping up

You've now seen the Observer pattern in action, transforming a complex authentication challenge into a clean, maintainable solution that connects your token rotation system with React's UI seamlessly.
What I've shown is just the foundation—there are several important pieces I intentionally left out for you to tackle as a challenge:

  • "Remember me" functionality – Try implementing this by conditionally switching between localStorage and sessionStorage based on the user's preference.
  • Request queue management – The current implementation is vulnerable to race conditions when multiple requests trigger token rotation simultaneously. Building a request queue would solve this elegantly.
  • Logout functionality.
  • Testing.

These challenges will help you deepen your understanding of the implementation we've walked through. The Observer pattern isn't just limited to authentication systems—it's a versatile tool that shines whenever you need different parts of your application to stay in sync with a shared state without tight coupling.

Have you put the Observer pattern to work in your own React apps? I'd love to hear what problems it solved for you!
Thanks for sticking around until the end — happy coding!