
Implementing Firebase analytics on a React project using SOLID principles
23 de novembro de 2024•5 min de leituraAbstract classes are not (always) bad
The problem
I recently came across a requirement at my job to implement analytics events on the extremely robust React application that I work on. Initially, I was requested to log events related to a notification feature that we were implementing, in order to have an idea of the user’s usage of the newest features. But soon, it was agreed upon that the analytics events could and should be extended to the whole app, since this could be an easy way of evaluating existing features and planning new ones, based on real data on end-user’s usage.
Firebase setup
Firebase provides a simple way to setup a project to start collecting analytics. Once you create a new project in the Firebase Console, it provides all of the configuration information needed to create the Analytics object:

We add this snippet to our project, and install the Firebase SDK with:
npm install firebaseAnd that is really all that’s needed to get started.
Now, in order to log an event in the interface, you need to call the logEvent method, provided by the Firebase SDK. The snippet below is an example of an event to log the click of a button:
import { logEvent } from "firebase/analytics";
logEvent(analytics, 'button_click', {
button_name: 'example_button',
timestamp: new Date().toISOString(),
viewport: 'mobile',
// ...
})The logEvent function receives 3 parameters:
- The
Analyticsinstance created on setup with the project’s information; - The event name, that is a string;
- The event parameters, that is an object which can contain fields of any type. This parameter signature is:
type EventPayload = {
[key: string]: any;
}Like so, I began adding individual logs to custom hooks to fire the events on components. However, as the requirements grew, it soon became a cumbersome task to keep track of all the events I had implemented, and to keep a standard between similar events parameters. Also, since there were many similar events, the code began to turn out repetitive and unpractical.
In order to overcome these issues, I decided to abstract the logic behind the events into a centralized system that encapsulates the event handling logic, ensuring consistency, reducing repetition, and improving maintainability.
SOLID implementation
To begin, I encapsulated the Firebase functionality into a single abstract class that could be easily extended to implement specific events. Since events only differ in their event name and parameters, the base class is abstracted by receiving this information during instantiation — the event name through the constructor and the parameters type as a TypeScript Generic:
import { analytics } from '@/lib/firebase';
import { logEvent } from 'firebase/analytics';
export abstract class AnalyticsBaseEvent<Payload extends { [key: string]: any }> {
private eventName: string;
protected constructor(eventName: string) {
this.eventName = eventName;
}
public log(payload: Payload) {
logEvent(analytics, this.eventName, payload);
}
}This design addresses key challenges by centralizing shared functionality into the base class, making it reusable for all event types. By isolating event handling logic from the rest of the application into a single class, this approach adheres to the Single Responsibility Principle (SRP), as the base class is solely responsible for managing the common logic for event logging.
Additionally, it aligns with the Dependency Inversion Principle (DIP) by ensuring that the application depends on the abstraction (the base class) rather than directly relying on Firebase’s implementation, making the system more flexible and easier to adapt to changes, such as swapping out Firebase for another analytics provider, for example.
To ensure consistency of the event names, I restricted these in an Enum, ensuring that each event class could only use predefined, standardized event names. This not only reduces the likelihood of typos or discrepancies but also provides better autocomplete support and type safety during development:
export enum AnalyticsEventTypes {
ButtonClickEvent = 'button_click',
// ...
}With this foundation, I created event-specific classes that extend the base class, defining unique event names and parameter structures. For example, the event for logging the click of a button was implemented as below:
import { AnalyticsBaseEvent } from '../core/analytics-base-event';
import { AnalyticsEventTypes } from '../types/event-types';
interface ButtonClickEventPayload {
button_name: string;
viewport: "mobile" | "desktop";
timestamp: string;
// ...
}
export class ButtonClickEvent extends AnalyticsBaseEvent<ButtonClickEventPayload> {
constructor() {
super(AnalyticsEventTypes.ButtonClickEvent);
}
}Each specific event class defines its payload structure through an interface and uses it as a TypeScript Generic to extend the base class. This approach ensures type-safe usage by strictly enforcing the parameters each event can accept. This design adheres to the Interface Segregation Principle (ISP), as each event is only concerned with the parameters it requires, avoiding unnecessary or bloated definitions.
Additionally, the constructor passes the event name to the base class, exemplifying the Open/Closed Principle (OCP), as new event types can be added by creating a new class without altering the base class or existing event logic.
In order to centralize the event classes creation, I also implemented a Factory class, that is responsible to create an instance of the correct event class based on the event name. This ensures that the creation logic is centralized and consistent, reducing duplication and keeping the rest of the application decoupled from the specifics of event instantiation:
import { ButtonClickEvent } from '../events';
import { AnalyticsEventTypes } from '../types';
const eventFactoryMap = {
[AnalyticsEventTypes.ButtonClickEvent]: () => new ButtonClickEvent(),
// ...
};
export class AnalyticsEventFactory {
public static createEvent<T extends AnalyticsEventTypes>(type: T): ReturnType<(typeof eventFactoryMap)[T]> {
const factory = eventFactoryMap[type];
if (!factory) throw new Error(`Event ${type} not supported`);
return factory() as ReturnType<(typeof eventFactoryMap)[T]>;
}
}The Factory class returns instances of the AnalyticsBaseEvent class, allowing the rest of the application to interact with the base class without needing to know the specifics of the event being logged. This ensures that any subclass, such as ButtonClickEvent, can be substituted for the base class without breaking the application, aligning with the Liskov Substitution Principle (LSP). This design keeps the code flexible and decoupled, simplifying future extensions.
With this implementation, logging interface events has become much simpler. To add a new event, I simply instantiate an event class using the factory and call the log function with the required parameters — all properly typed through the event object. For example, here’s how the button click event I mentioned earlier would be fired:
const event = AnalyticsEventFactory.createEvent(AnalyticsEventTypes.ButtonClickEvent)
event.log({
button_name: 'example_button',
timestamp: new Date().toISOString(),
viewport: 'mobile',
// ...
})Conclusion
Implementing the abstraction layer for the Firebase Analytics events, although increasing some complexity, significantly improved the maintainability, scalability, and consistency of the codebase. By adhering to SOLID principles, the implementation ensured that each class had a clear responsibility, was easy to extend with new events, and maintained type safety across the application. This design not only reduced repetitive code and potential errors but also provided a flexible and robust foundation for handling analytics in a growing project. The centralized factory and type-safe interfaces further streamlined event management, making the overall system easier to understand and adapt to future requirements.