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> { /* ... */ }
}
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 },
});
}
});
}
}
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;
};
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>,
);
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} />
))}
</>
);
}
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)