DEV Community

Cover image for How I'm Using Signals to Make My React App Simpler
Andy Jessop
Andy Jessop

Posted on

How I'm Using Signals to Make My React App Simpler

Recently, I’ve incorporated Signals from @preact/signals-react into my stack to make my application simpler, more maintainable, and easier to debug. Below, I’ll break down how I’m using Signals to implement a ChannelsApi module, make it available through context, and integrate it seamlessly into my components.

Why Signals?

Signals provide a reactive mechanism for managing state with minimal boilerplate and excellent performance. Unlike traditional state management tools (like React’s useState or useReducer), Signals automatically trigger updates only when necessary, reducing re-renders and making state changes predictable.

One problem I have with Signals is that it can be hard to structure your application if you're importing them as singletons and passing them around the app willy-nilly - it can get hard to track.

Below is my solution to this problem. I think it's pretty neat, and it's certainly enjoyable to work with. I will describe the following

  • The "API" modules, using a ChannelsApi as an example. An API module is the beating heart of the app, and contains all the business logic.
  • Side effects, how they are structured using horizontally connected APIs.
  • How the API data is provided to the app, via Signals and Context.
  • How the API data is consumed in the components.

Building the ChannelsApi Module

The ChannelsApi class is responsible for interacting with a backend to manage channel data. It leverages Signals to maintain a reactive state for the list of channels and the selected channel.

This is technically breaking some rules, namely the Single Responsibility Principle, but if you keep the classes small and focussed enough, the lack of abstractions can actually make the code much easier to follow. I will likely break it out when any single class gets too unwieldy. This is pragmatic - rather than dogmatic - programming.

Here’s the core implementation:

export class ChannelsApi {
  #client: Client;

  // Reactive state
  #channels = signal<Channel[]>([]);
  #selectedChannelId = signal<string | null>(null);

  // Computed state
  #selectedChannel = computed(() => {
    const id = this.#selectedChannelId.value;
    return this.#channels.value.find((ch) => ch.uuid === id);
  });

  constructor(client: Client) {
    this.#client = client;
  }

  // Getters for reactive data
  get channels(): Channel[] {
    return this.#channels.value;
  }

  get selectedChannelId(): string | null {
    return this.#selectedChannelId.value;
  }

  get selectedChannel(): Channel | undefined {
    return this.#selectedChannel.value;
  }

  // Setters for reactive state
  setSelectedChannelId(channelId: string | null): void {
    this.#selectedChannelId.value = channelId;
  }

  // Fetch, create, update, and delete channels
  // These methods update the back end and also the reactive state
  async fetchChannels(): Promise<void> { /* ... */ }
  // Here's a full example as it stands in the app. responsibilities for sure, but simple to follow no doubt.
  async createChannel(draftChannel: DraftChannel): Promise<Channel> {
    try {
      const { data, error } = await this.#client.insertChannel(draftChannel);

      if (error || !data) {
        throw new Error(error?.message ?? "Create failed.");
      }
      const createdChannel = data[0];

      this.#channels.value = [...this.#channels.value, createdChannel];

      notifications.show({
        title: "Success",
        message: "Channel created successfully.",
        color: "green",
      });

      return createdChannel;
    } catch (err) {
      notifications.show({
        title: "Error",
        message: err instanceof Error ? err.message : "Unknown error.",
        color: "red",
      });
      throw err;
    }
  }
  async updateChannel(channelUuid: string, updates: Partial<Channel>): Promise<Channel> { /* ... */ }
  async deleteChannel(channelId: string): Promise<boolean> { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

Although the ChannelsApi has mixed vertical responsibilities, I don't like to do the same horizontally. So, for example, I have a ChannelsRoutesApi that handles the crossover between Routes and Channels:

export class ChannelsRoutesApi {
  #channelsApi: ChannelsApi;
  #routerApi: RouterApi;

  constructor(channelsApi: ChannelsApi, routerApi: RouterApi) {
    this.#channelsApi = channelsApi;
    this.#routerApi = routerApi;

    effect(() => {
      const route = this.#routerApi.route;

      if (route === null) {
        this.#channelsApi.setSelectedChannelId(null);
      }

      const { name, params } = route as Route;

      if (name === "channel" && typeof params?.id === "string") {
        this.#channelsApi.setSelectedChannelId(params.id);
      } else {
        this.#channelsApi.setSelectedChannelId(null);
      }
    });

    effect(() => {
      if (this.#routerApi.route?.name !== "channels") {
        return;
      }

      const channels = this.#channelsApi.channels;

      if (channels.length) {
        // If there are channels, navigate to the first channel:
        this.#routerApi.navigate({
          name: "channel",
          params: { id: channels[0].uuid },
        });
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Key Points to Note on the API Layer

Signals for State: #channels and #selectedChannelId hold the core state, while #selectedChannel derives its value reactively using computed.
Imperative API: The class methods encapsulate CRUD operations, keeping the flow straightforward and cohesive.
Too many responsibilities: Everything is in one place, which makes it easy to follow and debug, but may need to be broken out if it gets bigger.

Context for Global Access

To make the ChannelsApi instance available throughout the application, I use React’s Context API. Additionally, useSignals() from @preact/signals-react/runtime ensures Signals remain reactive in components.

Each module with have a corresponding Context and Provider.

const ChannelsApiContext = createContext<ChannelsApi | null>(null);

export const ChannelsApiProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const client = useClient()
  const channelsApi = new ChannelsApi(client);

  return (
    <ChannelsApiContext.Provider value={channelsApi}>
      {children}
    </ChannelsApiContext.Provider>
  );
};

export const useChannelsApi = (): ChannelsApi => {
  useSignals();
  const context = useContext(ChannelsApiContext);
  if (!context) {
    throw new Error("useChannelsApi must be used within a ChannelsApiProvider");
  }
  return context;
};
Enter fullscreen mode Exit fullscreen mode

This approach avoids prop drilling entirely, as any component can simply use useChannelsApi() to access the ChannelsApi instance.

Composing the Application with Providers

In the root of the application, I compose all API providers using a utility function. This maintains a clean structure and simplifies scalability as new APIs are added.

const ApiProviders = composeProviders([
  AuthApiProvider,
  ProfilesApiProvider,
  RouterApiProvider,
  ChannelsApiProvider,
  MessagesApiProvider,
  ChannelMessagesApiProvider,
  ChannelsRoutesApiProvider,
]);

root.render(
  <ApiProviders>
    <App />
  </ApiProviders>,
);
Enter fullscreen mode Exit fullscreen mode

The ChannelsApiProvider is just one of many providers that compose the application’s context tree. Each provider encapsulates a specific domain of logic, ensuring modularity.

Using the API in Components

By injecting the ChannelsApi instance into components, data flows naturally without excessive boilerplate. Here’s an example where I use ChannelsApi and MessagesApi to display a channel’s messages:

export function ChannelMessagesList() {
  const messagesApi = useMessagesApi();
  const channelsApi = useChannelsApi();

  if (!channelsApi.selectedChannelId) {
    return null;
  }

  const messages = messagesApi.messages[channelsApi.selectedChannelId];

  return (
    <>
      {messages.map((message) => (
        <Message key={message.uuid} message={message} />
      ))}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Final Thoughts

I believe this setup allows for a very simple and high-performance application. There is no magic - the flow is easy to debug. The flow is imperative, which is simpler to understand - no mental gymnastics needed in this app.

I know classes aren't for some, but I do think that they are useful when building an application with Signals. Often you will see apps import Signals as singletons, but I'm never a fan of that because it makes it difficult to test. I've not touched on testing here, but you can see how I can easily test the ChannelsApi in node without any trouble, and I can even do integration testing between modules by instantiating any classes I need to test, e.g. RouterApi, ChannelsApi, and ChannelsRouterApi.

One thing I'm not certain about yet, but am thinking about, is whether or not I want to extend this pattern to components. For example, if I have a complex form with async behaviours, I can easily model that in this way and then use it in the component. This way, I'm bringing as much logic out of React as possible. But that's a thought for another day.

Top comments (0)