Skip to main content

Command Palette

Search for a command to run...

Architecting a Modular Product Announcement Framework in React

Updated
โ€ข10 min read
Architecting a Modular Product Announcement Framework in React
K

UI Engineer currently simplifying the process of building generative AI bots @yellow.ai. On a journey to make the web a better place by building engaging and performant UIs.

A streamlined announcement system for showing contextual notifications and announcements across the platform.

Features

  • ๐ŸŽฏ Smart Tracking: Tracks view counts and respects user limits

  • ๐Ÿš€ Global Access: Trigger announcements from anywhere

  • ๐Ÿ“ฑ Flexible Content: Any React component

  • ๐ŸŽ›๏ธ Conditional Display: Show based on paths or custom conditions

  • ๐Ÿ’พ Persistent Storage: User preferences stored in localStorage (can be extended to a database)

Architecture: Three Simple Pieces

  • ๐Ÿง  The Brain (announcementService) โ€” Decides when and where to show announcements, tracks user interactions, and prevents overexposure

  • ๐Ÿ”Œ The Connector (AnnouncementProvider) โ€” Gives any component a simple useAnnouncement() hook to trigger announcements

  • ๐Ÿ‘๏ธ The Display (AnnouncementModal) โ€” Renders announcements consistently using your existing modal system

Why this works:

  • Pluggable: Any component can show announcements without setup

  • Flexible: Route targeting, custom conditions, delays, force-show for testing

  • Safe: Built-in view limits and permanent dismissal options


๐Ÿง  The Brain: How AnnouncementService manages announcements?

Singleton service for managing announcement tracking and storage.

This service handles:

  • Announcement lifecycle: Managing announcement lifecycle (shown, dismissed, reset)

  • Storing: User preferences and dismissal states in localStorage

  • When to show?: Determining if announcements can be shown based on conditions and limits

Persistent Memory

 // Persists announcement state to localStorage
 private saveToStorage(): void {
    try {
      localStorage.setItem(
        ANNOUNCEMENT_TRACKING_DATA_LS_KEY,
        JSON.stringify(this.storage)
      );
    } catch (error) {
      console.error("Failed to save announcement tracking data:", error);
    }
  }


// Load announcement state
private loadFromStorage(): void {
  try {
    const stored = localStorage.getItem(ANNOUNCEMENT_TRACKING_DATA_LS_KEY);
    if (stored) {
      this.storage = JSON.parse(stored) ?? {};
    }
  } catch (error) {
    console.error("Failed to load announcement tracking data:", error);
    this.storage = {}; // Graceful fallback
  }
}

// Records when an announement has been shown
public recordAnnouncementShown(announcementId: string): void {
  const trackingData = this.getTrackingData(announcementId);
  this.storage[announcementId] = {
    ...trackingData,
    viewedCount: trackingData.viewedCount + 1,
    lastShownAt: Date.now(),
  };
  this.saveToStorage();
}

/**
  * Records that an announcement has been dismissed by the user
  * Can mark as permanently dismissed or just temporarily closed
  *
  * @param announcementId - Unique identifier for the announcement
  * @param permanent - If true, announcement will never be shown again (default: false)
*/
public recordAnnouncementDismissed(announcementId: string, permanent = false): void {
   const trackingData = this.getTrackingData(announcementId);

   this.storage[announcementId] = {
      ...trackingData,
      dismissed: permanent,
      dismissedAt: Date.now(),
   };

   this.saveToStorage();
}

Why this matters: Users stay logged in across sessions. Without persistence, they'd see the same announcements every time they visit. In a real-world scenario, this data would be persisted in a database.
Note: saveToStorage and loadFromStorage are private methods, to prevent access from outside.

When to show an announcement?

public canShowAnnouncement(announcement: AnnouncementConfig): boolean {
  const trackingData = this.getTrackingData(announcement.id);

  // Already permanently dismissed? Respect that choice
  if (trackingData.dismissed) return false; 

  // Hit the view limit? Don't spam
  const maxViewCount = announcement.maxViewCount ?? 3;
  if (trackingData.viewedCount >= maxViewCount) return false;

  // Custom condition failed? (wrong user type, feature disabled, etc)
  if (typeof announcement.condition === "function" && !announcement.condition()) {
    return false;
  }

  // Wrong page? Don't show
  if (announcement.targetPath) {
    const currentPath = window.location.pathname;
    if (announcement.targetPath instanceof RegExp) {
      if (!announcement.targetPath.test(currentPath)) return false;
    } else if (!currentPath.includes(announcement.targetPath)) {
      return false;
    }
  }

  return true; // All checks passed!
}

Why this matters: One function prevents all common announcement mistakes - showing too often, on the wrong pages, to the wrong users, or after they've said "never show this again."

Get announcement stats or reset existing state

/**
 * Gets complete statistics for a specific announcement
 *
 * @param announcementId - Unique identifier for the announcement
 * @returns Complete tracking data including view count, timestamps, and dismissal status
*/
 public getAnnouncementStats(announcementId: string): AnnouncementTrackingData {
    return this.getTrackingData(announcementId);
 }

/**
 * Resets all tracking data for a specific announcement
 * Useful for testing or when user requests to see an announcement again
 *
 * @param announcementId - Unique identifier for the announcement
*/
 public resetAnnouncementTracking(announcementId: string): void {
    delete this.storage[announcementId];
    this.saveToStorage();
  }

Why this matters: Developers need to test, campaigns get relaunched, and sometimes you need to override the rules. This gives you an escape hatch without breaking the system.


๐Ÿ”ง The Announcement Configuration

Every announcement is defined by an AnnouncementConfig object. Think of it as your announcement's DNA:

export interface AnnouncementConfig {
  id: string;                    // Unique identifier for tracking
  content: ReactNode;            // What to show (your React component)
  modalProps?: ModalProps;       // How to style the modal
  maxViewCount?: number;         // How many times to show (default: 3)
  showDelay?: number;           // Delay before showing (milliseconds)
  targetPath?: string | RegExp;  // Which pages to show on
  condition?: () => boolean;     // Custom logic for when to show
  onShow?: () => void;          // Called when shown (for analytics)
  onOk?: () => Promise<boolean> | boolean | void;     // Handle "OK" button
  onDismiss?: (dismissedAfterOk?: boolean) => boolean; // Handle dismissal
}

What Each Field Does (And Why You Need It)

id - Your announcement's unique identifier, consider it like the key from React Query

  • Used for tracking views, dismissals, and preventing duplicates

  • Make it descriptive: "feature-launch-v1" not "popup1"

content - The actual announcement UI

  • Pass any React component: <WelcomeMessage /> or <div>Hello!</div>

  • Keep it focused - users scan, don't read

maxViewCount - Prevents announcement fatigue

  • Default is 3 times max per user

  • Set to 1 for critical one-time messages

  • Set higher for tips users might need to see multiple times

targetPath - Show only where it matters

  • String: "/dashboard" shows on any dashboard page

  • Regex: /\/project\/\d+/ for specific URL patterns

  • Skip for global announcements

condition - Your smart targeting logic

  • Check user type: () => user.isPremium

  • Feature flags: () => isFeatureEnabled('newEditor')

  • Time-based: () => Date.now() < campaignEndDate

showDelay - Reduces jarring interruptions

  • Give users 500ms to orient before showing

  • Especially important on page loads

onOk / onDismiss - Handle user actions

  • onOk returning true closes the modal automatically

  • onDismiss returning true marks it permanently dismissed


๐Ÿ”Œ The Connector: The AnnouncementProvider and useAnnouncement Hook

The AnnouncementProvider wraps your app and exposes a dead-simple hook that any component can use. It acts as a bridge between your application and the AnnouncementService and renders a modal to show the announcement content.

// Setup once at your app root
function App() {
  return (
    <AnnouncementProvider>
      <YourAppContent />
    </AnnouncementProvider>
  );
}

// Use anywhere in your app
function AnyComponent() {
  const { showAnnouncement } = useAnnouncement();
}

The Hook

The useAnnouncement hook exposes mainly three essential functions:

1. showAnnouncement()

This is your primary tool - 90% of use cases only need this function.

const handleShowAnnouncement = useCallback(
    (announcement: AnnouncementConfig, forceShow?: boolean) => {
      if (forceShow) {
        setActiveAnnouncement(announcement);
        return;
      }

       // check if announcement can be shown
      if (!announcementService.canShowAnnouncement(announcement)) {
        console.warn(
          `Announcement "${announcement.id}" cannot be shown due to conditions or limits`
        );
        return;
      }
      // show the announcement after some delay(if specified)
      const delay = announcement.showDelay || 0;
      setTimeout(() => {
         setActiveAnnouncement((prev) => {
          if (prev?.id !== announcement.id) {
            // this is to prevent showing the same announcement multiple times
            return announcement;
          }
          return prev;
        });
        announcementService.recordAnnouncementShown(announcement.id);
        announcement.onShow?.();
      }, delay);
    },
    []
  );

Usage:

import { useAnnouncement } from "components/Announcement/AnnouncementProvider";

export function FeatureIntro() {
  const { showAnnouncement } = useAnnouncement();

  const handleShowWelcome = () => {
    showAnnouncement({
      id: "dashboard-welcome-v1",
      content: <div>Welcome to your new dashboard! Heres what changed...</div>,
      maxViewCount: 2,           // Show max twice
      showDelay: 500,           // Wait half second after page load
      targetPath: "/dashboard", // Only on dashboard
      onOk: () => true,         // Close when they click OK
      onDismiss: (afterOk) => afterOk, // Permanent only if they clicked OK
    });
  };

  return <button onClick={handleShowWelcome}>Dashboard V2</button>;
}

What happens behind the scenes:

  1. Checks conditions - Validates routes, view limits, custom logic

  2. Respects delays - Waits for showDelay before appearing

  3. Tracks automatically - Records that the user saw this announcement

  4. Handles conflicts - Won't show if another announcement is active

  5. Fails gracefully - Logs warnings instead of breaking your app

Advanced usage with force override:

// Sometimes you need to bypass all rules (testing, admin overrides)
showAnnouncement(config, true);

2. dismissAnnouncement() - Programmatic Control

Need to close an announcement programmatically before the user dismisses it? This function gives you the control to do that

  const handleDismissAnnouncement = useCallback(
    (announcementId: string, permanent?: boolean) => {
      // track the dismissal
      announcementService.recordAnnouncementDismissed(
        announcementId,
        permanent
      );
      // close the modal
      setActiveAnnouncement(null);
    },
    []
  );

Usage:

const { dismissAnnouncement } = useAnnouncement();

// Close an announcement
dismissAnnouncement("announcement-id");

// Close permanently (user will never see it again)
dismissAnnouncement("announcement-id", true);

When you need this:

  • User completes an action that makes the announcement irrelevant

  • Time-based dismissal (auto-close after 10 seconds)

  • Error handling (close announcement when something goes wrong)

3. updateAnnouncementConfig() - Dynamic Updates

Perfect for multi-step announcements or when content needs to change for the active announcement on the fly.

  const handleUpdateAnnouncementConfig = useCallback(
    (announcement: AnnouncementConfig) => {
      setActiveAnnouncement((prev) => {
        // should only update the existing announcement
        if (prev?.id !== announcement.id) {
          return prev;
        }
        return announcement;
      });
    },
    []
  );

Real-World Example: Two-Step Feature Announcement

const TWO_STEP_ANNOUNCEMENT_ID = "two-step-announcement-v1";

function OnboardingFlow() {
  const { showAnnouncement, updateAnnouncementConfig } = useAnnouncement();

  const moveToStep2 = () => {
    updateAnnouncementConfig({
      id: TWO_STEP_ANNOUNCEMENT_ID,
      content: (
        <div className="space-y-2 h-[472px]">
          <ul className="list-disc pl-5">
            <li>No disruption to current analytics</li>
            <li>Existing reports will remain accessible</li>
          </ul>
        </div>
      ),
      modalProps: {
        title: "Review and Accept",
        okText: "Confirm Upgrade",
        cancelText: "Cancel",
      },
      onOk: () => true, // close on final step
    });
  };

  const showTwoStepAnnouncement = () => {
    const step1Config: AnnouncementConfig = {
      id: TWO_STEP_ANNOUNCEMENT_ID,
      content: (
        <div className="space-y-2">
          <p className="font-medium">Introducing Web Analytics</p>
          <p>
            All new analytics dashboard, built to track your website traffic.
            Click Next to review changes.
          </p>
        </div>
      ),
      modalProps: { title: "New Feature", okText: "Next" },
      onOk: () => {
        moveToStep2();
        return false; // do not close, move to next step
      },
      onDismiss: () => false,
    };
    showAnnouncement(step1Config);
  };
}

When you need this:

  • Multi-step tutorials or onboarding flows

  • Dynamic content based on user actions

  • Progress indicators that update in real-time

  • Form wizards within announcements


Complete Hook API

const {
  // Core functions (what you'll use 90% of the time)
  showAnnouncement,           // Show an announcement
  dismissAnnouncement,        // Close programmatically  
  updateAnnouncementConfig,   // Update content on the fly

  // State inspection
  activeAnnouncement,         // What's currently showing

  // Advanced utilities
  canShowAnnouncement,        // Check if announcement would show
  getAnnouncementStats,       // View tracking data
  resetAnnouncementTracking   // Clear all tracking (dev/testing)
} = useAnnouncement();

Common Pitfalls

โŒ Announcement Fatigue

// Bad: No limits
{ id: "tip-1", content: <Tip />, maxViewCount: 999 }

// Good: Reasonable limits  
{ id: "tip-1", content: <Tip />, maxViewCount: 3 }

โŒ Jarring Interruptions

// Bad: Shows immediately on page load
{ id: "welcome", content: <Welcome />, showDelay: 0 }

// Good: Let them orient first
{ id: "welcome", content: <Welcome />, showDelay: 1000 }

โŒ Wrong Place, Wrong Time

// Bad: Global announcement for specific feature
{ id: "editor-tip", content: <EditorTip /> }

// Good: Targeted to where it matters
{ 
  id: "editor-tip", 
  content: <EditorTip />,
  targetPath: "/editor",
  condition: () => user.hasProject && !user.hasUsedEditor
}

โŒ Unstable IDs

// Bad: Changes every render
{ id: `welcome-${Math.random()}`, content: <Welcome /> }

// Good: Stable, descriptive IDs
{ id: "welcome-dashboard-v2", content: <Welcome /> }

โœ… Best Practices

  1. Namespace your IDs: feature-name-version not popup1

  2. Start conservative: Better to show too little than too much

  3. Respect dismissals: If they said no, honor it

  4. Keep content scannable: Users don't read, they glance

  5. Target precisely: Wrong audience = ignored announcements

    Route-Based Targeting

     // Simple contains check
     targetPath: "/analytics/dashbaord"  // Shows only on dashboard page
    
     // Exact regex matching  
     targetPath: /\/project\/\d+\/settings$/  // Only project settings pages
    
     // Multiple routes
     condition: () => {
       const path = window.location.pathname;
       return path.includes('/dashboard') || path.includes('/reports');
     }
    

    User & Feature Targeting

     // User type targeting
     condition: () => user.role === 'admin' && user.isActive
    
     // Feature flag integration
     condition: () => isFeatureEnabled('newEditor')
    
     // Time-sensitive campaigns
     condition: () => {
       const now = Date.now();
       const campaignStart = new Date('2024-01-01').getTime();
       const campaignEnd = new Date('2024-02-01').getTime();
       return now >= campaignStart && now < campaignEnd;
     }
    

Sample Nextjs project code

That's it! You now have a bulletproof system for shipping announcements that users actually appreciate instead of reflexively closing. Keep building ๐Ÿ—๏ธ