Reactive Polymorphism in React (and why it makes abstractions painful)

Zachary Weidenbach
10 min readMay 31, 2024

--

Reactivity in React creates a kind of polymorphism for consumers of reactive data sources. Consider the api for Redux:

const someStoreValue = useSelector((state) => state.someStoreValue);

This should look familiar to pretty much anyone who has worked with Redux. But there is another way to access values from the Redux store on-demand:

const store = useStore();
const someStoreValue = store.getState().someStoreValue;

This may look less familiar if you have never needed a value from the Redux store immediately rather than waiting for the value to propagate to subscribed components via useSelector.

The main point of this writeup is that these different access mechanisms pose a challenge when designing abstraction layers that can be consumed both by standard React components and by control flows that are invoked imperatively. Resolving this polymorphism involves creating reactive and non-reactive implementations of abstracted interfaces that interact with reactive data sources. This makes abstractions costlier than they otherwise would be in a non-reactive system. In a later article, I will discuss how this cost is compounded when asynchronous interfaces must be implemented for reactive and non-reactive consumers.

To illustrate these arguments, I will be using the age-old example of a Todo app. Todo apps are too simple to justify this amount of layering. So, please suspend your disbelief on the basic premise of layering a Todo app in this way.

A hosted example of the Todo app can be found here:

https://dataquail.github.io/react-ddd-todo-app

Accidental Complexity and Implementation Details

(skippable section if you know what these mean)

To better understand why I am layering the way that I am in this app, it is helpful to understand accidental complexity, and the concept of implementation details. In the world of backend development, it is common to hear these terms used, particularly when it comes to dependencies the application has on different technologies.

Perhaps the most common place to hear about this is with data persistence. If a backend uses Postgres for data persistence, then the code for managing database connections, making queries, pools, etc. are all examples of accidental complexity. Put another way, data persistence is a technical requirement for the technology used to build the software. It is not essential to the business problems that are solved by the software, but rather a technical detail for how to solve the problems. So, to mitigate the amount the code specific to any particular data persistence technology is allowed to become entangled with business logic, interfaces are put in place to form a logical boundary between the two. You see this with “Repositories” in Domain Driven Design, and somewhat with DAOs in java.

The fact that a SQL query must occur to retrieve some data from a database is an “implementation detail” of a specific data persistence technology (Postgres in this example). It therefore should not be allowed to “leak out” of the logical boundary in which it resides (ex. a repository).

Data Persistence Abstraction In React

When we apply this thinking to the frontend, especially React frontends, the question of “which state management library should I use?” becomes less important. Any of the options available (react context, redux, zustand, etc.) are fairly interchangeable, and the means by which data is retrieved and operated on becomes an implementation detail that hides behind a common interface. Abstracting in this way should make the interaction between the repository interface and its consumers very stable. So much so that it would be trivial to swap out one state management solution for another (better modularity). This should also make our code more testable, such that we can mock interfaces more easily, and test different parts of the application in isolation.

Some might quibble over whether reactive state management can be consider “data persistence”. It is true that this data is normally ephemeral, such that it is lost on fresh loads of the app, unless something like redux-persist is also used to persist and rehydrate the store. For this example, we are considering it persistent, insofar as it is persists in memory.

Designing an abstract interface

export type ITodoRepository = {
save: (todo: Todo) => void;
saveAll: (todoList: Todo[]) => void;
delete: (id: string) => void;
deleteAll: () => void;
getAll: () => Todo[];
getOneById: (id: string) => Todo | undefined;
};

export type ITodoRepositoryReactive = {
useGetAll: ITodoRepository['getAll'];
useGetOneById: ITodoRepository['getOneById'];
};

Anyone that has made repository abstractions may recognize this to some extent. It defines a very CRUD-y style interface for the repository. Such that we can think about it like a simple collection. It returns domain entities (Todo). But there is something a little odd. What is the ITodoRepositoryReactive interface? To better understand what this is and why it’s necessary, I need to talk about who will be consuming these interfaces.

There are two main consumers: domain use-cases, and UI components. Domain use-cases will pretty much only use the ITodoRepository because use-cases read like procedural, top-down functions. Here is an example for marking a Todo item as completed

export class MarkTodoAsCompleted {
constructor(private readonly todoRepository: ITodoRepository) {}

public execute(todoId: string): void {
const todoToComplete = this.todoRepository.getOneById(todoId);

if (!todoToComplete) {
throw new Error(`Todo with id ${todoId} not found`);
}

this.todoRepository.save(todoToComplete.complete());

return;
}
}

This use-case expects to be injected with an ITodoRepository interface. It fetches the todo that it needs to mark as completed. It protects against domain invariants if the Todo doesn’t actually exist, and it persists the change to the todoRepository . This is wildly overkill for a Todo app, but not for more complicated apps that need a lot of orchestration as a result of actions taken by the user.

The other kind of consumer is good ol’ React UI components:

export const TodoRedux = () => {
const todoRepository = useTodoRepository();
const todoList = todoRepositoryReactive.useGetAll();
const markTodoAsCompleted = useMarkTodoAsCompleted();
const markCompletedTodoAsNotComplete = useMarkCompletedTodoAsNotComplete();

return (
<PageContainer>
<NavBar />
<Divider />
<Space h="xl" />
<PageContent>
<Container>
<Group justify="space-between" align="start">
<Title order={1}>Todo List</Title>
<AddNewTodoForm todoRepository={todoRepository} />
</Group>
<TodoList
todoList={todoList}
markTodoAsCompleted={markTodoAsCompleted}
markCompletedTodoAsNotComplete={markCompletedTodoAsNotComplete}
todoRepository={todoRepository}
/>
</Container>
</PageContent>
</PageContainer>
);
};

Above is not the best example because so much is happening, but you can see const todoList = todoRepositoryReactive.useGetAll(); and how the todoList is passed down into <TodoList> props. This is because the frontend still needs to be updated reactively when values in the repository change.

At a high level, I am making it easy to swap between different TodoRepository implementations:

Redux Implementation Example

Redux is pretty well known among the React community, so it feels like the best starting point for an example. First we must define a slice and reducer:

import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import { TodoRecord, TodoDictionary } from '../TodoRecord';

const initialState = {
dict: {} as TodoDictionary,
};

export const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
saveTodo: (state, action: PayloadAction<TodoRecord>) => {
state.dict[action.payload.id] = action.payload;
},
saveAllTodos: (state, action: PayloadAction<TodoRecord[]>) => {
const newDictToSave = action.payload.reduce((acc, todo) => {
acc[todo.id] = todo;
return acc;
}, {} as TodoDictionary);
state.dict = newDictToSave;
},
deleteTodo: (state, action: PayloadAction<string>) => {
delete state.dict[action.payload];
},
deleteAllTodos: (state) => {
state.dict = {};
},
},
});

export const { saveTodo, saveAllTodos, deleteTodo, deleteAllTodos } =
todosSlice.actions;
export const todosReducer = todosSlice.reducer;

You’ll notice the interface maps closely to the repository interface. But this doesn’t necessarily need to be true. There could be times that the actual implementation might have less methods, some of which are reused between different methods on the repository implementation.

And the implementation of the todoRepository

export class TodoRepositoryReduxImpl implements ITodoRepository {
constructor(
private readonly appStore: AppStore,
private readonly appDispatch: AppDispatch,
) {}

public save(todo: Todo): void {
this.appDispatch(saveTodo(toTodoRecord(todo)));
}

public saveAll(todoList: Todo[]): void {
this.appDispatch(saveAllTodos(todoList.map(toTodoRecord)));
}

public delete(id: string): void {
this.appDispatch(deleteTodo(id));
}

public deleteAll(): void {
this.appDispatch(deleteAllTodos());
}

public getAll(): Todo[] {
const todosDictionary = this.appStore.getState().todo.todos.dict;
return getAllTodosUtil(todosDictionary);
}

public getOneById(todoId: string): Todo | undefined {
const maybeTodoRecord = this.appStore.getState().todo.todos.dict[todoId];
return getOneByIdTodosUtil(maybeTodoRecord);
}
}

Some things to note, there is a mapper function that maps incoming domain entities into the TodoRecord type that the redux slice expects to store. As well there is a mapper function to map TodoRecord types coming out of redux into domain entities, though this mapper is only used in getAllTodosUtil and getOneByIdTodosUtil . These utils were made to be reused in both the imperative implementation featured above, and also the “reactive” implementation.

Before I show the reactive implementations, I want to point some other things out. Notice this implementation expects to be injected with dependencies ( appStore and appDispatch ). Otherwise, it hides the implementation details of using redux. Consumers of this implementation are agnostic to the fact of a “store” or a “dispatch” call entirely. It also imperatively retrieves values from the store with this.appStore.getState() which ensures that it has the most up-to-date value in the store, rather than being beholden to React’s rerender lifecycle to get the value when it has changed.

Now, the reactive implementations:

export const useGetAllReduxImpl: ITodoRepositoryReactive['useGetAll'] = () => {
const todoDictionary = useAppSelector((state) => state.todo.todos.dict);
return useMemo(() => getAllTodosUtil(todoDictionary), [todoDictionary]);
};

export const useGetOneByIdReduxImpl: ITodoRepositoryReactive['useGetOneById'] =
(todoId: string) => {
const todo = useAppSelector((state) => state.todo.todos.dict[todoId]);
return useMemo(() => getOneByIdTodosUtil(todo), [todo]);
};

These use the familiar useSelector hook from redux (implemented as useAppSelector for type safety). Consumers of these hooks will be rerendered when the value changes in Redux, the same as using useSelector directly.

Dependency Injection

Dependency injection in React involves a fair amount of boilerplate. Here is the TodoRepositoryReduxImpl being injected:

const useTodoRepositoryReduxImpl = (): ITodoRepository => {
const appStore = useAppStore();
const appDispatch = useAppDispatch();

return useMemo(
() => new TodoRepositoryReduxImpl(appStore, appDispatch),
[appStore, appDispatch],
);
};

While the Redux store is a global singleton, it is generally considered bad practice to import it around directly. It’s easy to make circular dependencies doing this. As well for other implementations, like React’s own context provider, a hook is required to access the context in the component tree, so it must follow a similar pattern of injection.

The dependency injection for the reactive implementation isn’t even really dependency injection

const todoRepositoryReactiveReduxImpl: ITodoRepositoryReactive = {
useGetAll: useGetAllReduxImpl,
useGetOneById: useGetOneByIdReduxImpl,
};

However, it does aggregate the interface of available reactive hooks behind a single object. This makes the consumer code look like this

const todoList = todoRepositoryReactive.useGetAll();

Wait, so why is the reactive implementation necessary again?

React components also need to consume the data in the repository. And React components need the data sources in which the consume data from to be reactive. Otherwise, the react components using the todoRepository won’t rerender when the data changes. Here is a diagram showing the flux flow of information with redux (but the same is true with React Context, Zustand, or any other state management library).

Testing

Testing becomes more straightforward. Although, because hooks are used as the mechanism for dependency injection, I am still using @testing-library/react and the renderHook helper.

  const getReduxTestHarness = () => {
const { ReduxWrapper } = getReduxWrapper();
return renderHook(useReduxTodoRepository, {
wrapper: ({ children }: ChildrenProps) => (
<ReduxWrapper>{children}</ReduxWrapper>
),
});
};

const getTestHarness = (
implementation: 'redux' | 'reactContext' | 'zustand',
) => {
if (implementation === 'redux') {
return getReduxTestHarness();
} else if (implementation === 'zustand') {
return getZustandTestHarness();
} else {
return getReactContextTestHarness();
}
};

it.each(implementations)(
'Impl: %s create() adds a new todo',
async (implementation) => {
const { result } = getTestHarness(implementation);
const todo = Todo.create('test todo');
act(() => {
result.current.save(todo);
});
expect(result.current.getOneById(todo.id)).toEqual(todo);
},
);

Because all the implementations build to a common interface, my test code just needs to get the correct test harness and run the same test code against it. I can exhaustively test every implementation with the same test code. This is perhaps not that compelling when most people aren’t using multiple state management libraries. But it does illustrate something important.

My tests are agnostic to the implementation details of each state management implementation. My tests are testing the behavior of the interface for each implementation for correctness, without getting bogged down in mocking of initial state, or mocking the specific state management tool altogether.

What about the UI?

The view layer, aka React UI components are very thin:

            <Checkbox.Card
radius="md"
checked={Boolean(todo.completedAt)}
onClick={() => {
const isCompleted = Boolean(todo.completedAt);
if (isCompleted) {
markCompletedTodoAsNotComplete.execute(todo.id);
} else {
markTodoAsCompleted.execute(todo.id);
}
}}
>

Above is the code for the Checkbox, onClick conditionally calls the markTodoAsCompleted or the markCompletedTodoAsNotComplete use-cases. The UI is minimal and therefore easier to test as well. Though when it comes to bang-for-your-buck tests, I would argue that it is far more valuable to test the repository and not the UI. The UI is mostly an afterthought here.

Conclusion

Like anything, there are tradeoffs to any abstraction that creates indirection. But that’s not really what this writeup is about. Pretty much all of the tradeoffs that apply to DDD on the backend also apply to DDD on the frontend.

The main point I’m trying to make is that abstractions involving reactive data sources are especially difficult to implement in React. I suspect this is actually a bedrock consequence of reactive systems, and not unique to React itself, but I lack the experience in other reactive systems to say this definitively. The aforementioned “polymorphism” that is introduced by reactive and non-reactive consumers of these abstracted interfaces is the culprit, as far as I can tell. To anyone currently struggling to wrestle their data sources behind abstracted interfaces in React, I hope you found this writeup helpful.

The repository in which these code samples were taken from can be found here:

Hosted working example of the app:

https://dataquail.github.io/react-ddd-todo-app

--

--