Recently I've been working on a project that I implemented event providers to handle events without specific libs. So I thought it would be a good idea to write about it.

Table of contents:

Prologue

In software development, event handling is a crucial aspect of building interactive applications. While there are many libraries and frameworks available for handling events, sometimes you might want a more lightweight, customized solution that fits your specific needs.

This article explores how to implement providers - a pattern that allows for flexible, decoupled handling without relying on specific third-party libraries. We'll look at how to create a simple yet powerful event system that can be used across your application.

The approach we'll discuss offers several benefits:

  • Lightweight: No external dependencies required
  • Type-safe: When used with TypeScript, provides full type safety
  • Flexible: Can be adapted to various use cases
  • Decoupled: Helps maintain separation of concerns

Let's dive into how we can implement and use event providers effectively.

The Basics

  • What is a provider in React?

Providers are a pattern that allows use context to pass data/functions through the component tree. Also, they are a way to manage dependencies in a more flexible way.

In React, you can use Language Providers, for language handling, or Theme Providers, for theme handling. So, can we create a provider for everything we want to handle? Yes, we can!

Let's understand the basic structure of a provider:

  1. Context Creation: First, we create a context that will hold our data and functions
  2. Provider Component: Then, we create a provider component that will wrap our application
  3. Provider Logic: Inside the provider, we implement the logic we want to handle
  4. Consumer Usage: Finally, we use hooks to consume the provider's data/functions

How to create providers

In this example, I'll demonstrate how to create an event provider - a provider that handles event emiting and registering across your application.
First, let's set up the project structure to organize our code.

In your project, create a file called event-provider.ts.

├── src
│   ├── providers
│   │   └── event-provider.ts
│   ├── components
│   │   └── my-component.tsx
│   └── App.tsx

Now, we need to create a type for our context and then create the context itself:

// Defines the structure of an event with a generic type parameter T
// - code: unique identifier for the event
// - callback: function to be called when event is emitted, receives data of type T
type EventSchema<T = unknown> = {
  code: string;
  callback: (data: T) => void;
};

// Interface defining the methods available in our event context
// - register: adds a new event handler
// - unregister: removes an event handler by code
// - emit: triggers an event with optional data
interface EventContextType {
  register<T = unknown>(event: EventSchema<T>): void;
  unregister(code: string): void;
  emit<T = unknown>(code: string, data?: T): void;
}

// State type holding a Map of event handlers
// - events: Maps event codes to their callback functions
type State = {
  events: Map<string, (data?: unknown) => void>;
};

// Create a context for events with no default value since it will be provided by EventProvider
const EventContext = createContext<EventContextType | undefined>(undefined);

Then, we need to create a reducer to manage our event subscriptions. The reducer will handle registering new event handlers and removing existing ones in an immutable way:

// Define the possible actions for our reducer:
// - REGISTER_EVENT: adds a new event handler with its schema
// - REMOVE_EVENT: removes an event handler by its code
type Action = { type: 'REGISTER_EVENT'; event: EventSchema<unknown> } | { type: 'REMOVE_EVENT'; code: string };

// Takes the current state and an action, returns the new state
const eventReducer = (state: State, action: Action): State => {
  switch (action.type) {
    case 'REGISTER_EVENT': {
      // Create a new Map from existing events to maintain immutability
      const newEvents = new Map(state.events);
      // Check if event code already exists to prevent duplicates
      if (newEvents.has(action.event.code)) {
        // EventError is a custom error class - creating custom errors is a good practice!
        // I recommend read this article (pt-bt): https://dev.to/he4rt/criando-exceptions-para-impressionar-no-teste-tecnico-2nie
        throw EventError.eventAlreadyRegistered(action.event.code);
      }
      // Add the new event callback to the Map
      newEvents.set(action.event.code, action.event.callback);
      // Return new state with updated events Map
      return {
        ...state,
        events: newEvents,
      };
    }
    case 'REMOVE_EVENT': {
      // If event code doesn't exist, return current state unchanged
      if (!state.events.has(action.code)) {
        return state;
      }
      // Remove the event from the Map
      state.events.delete(action.code);
      // Return new state with updated events Map
      return {
        ...state,
        events: new Map(state.events),
      };
    }
    default:
      // Return unchanged state for unknown action types
      return state;
  }
};

Now, let's create the event provider and implement the functions mentioned above:

// EventProvider component that manages event registration and emission
export function EventProvider({ children }: { children: React.ReactNode }) {
  // Initialize state with an empty Map to store event callbacks
  const [state, dispatch] = useReducer(eventReducer, {
    events: new Map<string, (data?: unknown) => void>(),
  });

  // Function to unregister/remove an event handler by its code
  const unregister = useCallback((code: string) => {
    dispatch({ type: 'REMOVE_EVENT', code });
  }, []);

  // Function to register a new event handler
  // Returns a cleanup function to unregister the event when needed
  const register = useCallback(
    <T = unknown,>(event: EventSchema<T>) => {
      dispatch({
        type: 'REGISTER_EVENT',
        event: {
          code: event.code,
          callback: event.callback as (data?: unknown) => void,
        },
      });

      return () => unregister(event.code);
    },
    [unregister]
  );

  // Function to emit/trigger an event with optional data
  // Throws error if event is not registered
  const emit = useCallback(
    (code: string, data?: unknown) => {
      const event = state.events.get(code);

      if (!event) {
        throw EventError.eventNotRegistered(code);
      }

      event(data);
    },
    [state.events]
  );

  // Memoize context value to prevent unnecessary re-renders
  const contextValue = useMemo(() => ({ register, unregister, emit }), [register, unregister, emit]);

  // Provide the event management functions to children components
  return <EventContext value={contextValue}>{children}</EventContext>;
}

Finally, we need to create a custom hook to access the provider's data and functions:

// Hook to consume the event provider context
export function useEvents() {
  // Gets the context value
  const context = use(EventContext);

  if (context === undefined) {
    // Throws error if context is undefined (provider not found)
    throw new ProviderError({
      hookName: 'useEvents',
      providerName: 'EventProvider',
    });
  }

  // Returns the context value with register, unregister and emit functions
  return context;
}

How to use providers

In a determined component, we can use the hook to consume the provider's data/functions:

// In your App.tsx
function App() {
    return (
        <EventProvider>
            <MyComponent />
            <AnotherComponent />
        </EventProvider>
    );
}

// In MyComponent.tsx
// Component that demonstrates emitting events
function MyComponent() {
    // Get the emit function from the events context
    const { emit } = useEvents();

    // Emit an event to open the language loading modal with some data
    emit(EventCodeEnum.OPEN_LANG_LOADING_MODAL, {data: 'some-data'});

    // Render a simple UI with a button
    return (
        <div>
            <h2>Event Sender</h2>
            <button onClick={handleButtonClick}>
                Send Event
            </button>
        </div>
    );
}

// In AnotherComponent.tsx
// Component that listens for events
function AnotherComponent() {
    // Get register and unregister functions from events context
    const { register, unregister } = useEvents();

    useEffect(() => {
        // Register a callback function to handle events with the given code
        register({
        code: code,
        callback: ({ data }: { data: string }) => {
            console.log(data)
            },
        });

        // Cleanup: unregister the event handler when component unmounts
        return () => {
            unregister(code);
        };
    }, [code]); // Re-run effect if code changes

    // Render child component
    return (
        <>
            <YourComponent />
        </>
    );
}

Best Practices

When implementing providers in React, consider the following best practices:

  1. Meaningful Names: Use clear, descriptive names that indicate the provider's purpose and the data/functionality it provides (e.g., AuthProvider, ThemeProvider).

  2. Type Your Context: Create specific TypeScript interfaces/types for your context value. Your future self will thank you for adding proper type definitions.

  3. Custom Error Handling: Implement dedicated error classes and meaningful error messages to help with debugging. This makes it easier to identify and fix issues when they occur (e.g., EventNotRegisteredError, InvalidEventDataError).

Conclusion

Creating custom providers is a powerful pattern that can greatly enhance the modularity and maintainability of your React applications. With this approach, you can encapsulate and share functionality, state, and business logic across components in a clean and reusable way.

By implementing proper type safety, error handling, and following best practices, you can build robust providers that scale with your application and make your codebase more maintainable.

I hope you found this article helpful! As this is my first article, thank you for reading until the end. Don't forget to stay hydrated and eat fruits!

References: