Event-Driven Architecture in React
Organize domain logic to build a solid and scalable app
Every React project needs architecture. With a solid architecture, your app will become a more testable, solid, and scalable app.
However, React projects can easily get unorganized.
Because we can put all the logic into components and we are (often) lazy enough to do so. This will bite you back when the project tries to grow.
So before you see the monstrous and unorganized components, you must sort things out. This is what I did recently.
I will introduce an architecture pattern you can apply to your React projects.
I usually call it the "Event-Driven" pattern.
Background/Motivation
Let me tell you more about the background. My team was seeing a "big ball of mud" in the codebase.
Hooks and components were using each other dependently, and it was when the organization decided to give it more features, which seemed nearly impossible.
The app already had a tremendous amount of logic to handle(It was a kind of browser game played by multi people).
We were using "Store hooks" to hold domain model states, but there was no specific place to handle app-wide events.
Therefore, UI components and hooks were using each other dependently to handle different state logic.
As a result, it was really hard to see the big picture.
We had to come up with a solid architecture to organize this big ball of mud. It was time to pay the debt.
"Event-Driven" Architecture
So, I suggested changing the architecture centering around the app-wide "events".
Look at the following diagram:
There are three layers here: Store, Service, and UI. So it's got another indirection layer.
Each of them has the following responsibilities.
Store
Hold states
Expose states
Provide functions to modify states
but do NOT access other stores' states
Service
Detect events
Consume events
Provide "commands"
UI (Components)
Query stores to get data
Render UI according to the current state
The Benefits of EDA
With this Event-driven architecture (EDA) I described above, we can get the following benefits:
More clarity on the app's domain logic, so that devs can add more features confidently
More testability (= more unit tests) on each logic, so that junior devs in the team can notice bugs
Let me elaborate more on this pattern in the following sections.
Demo
I've built an example demo that showcases the pattern: stackblitz.com/edit/eda-react?file=src%2FAp..
You can also clone the demo: github.com/yozibak/event-driven-react
I'm going to explain the architecture alongside this repository.
Extract App-Wide Events (Separation of Concerns)
The core concept of the event-driven pattern is to extract app-wide events.
Look at the plant simulator demo app. Several things are going on in this app (so-called "domain logic"):
Weather changes randomly every second
If it's rainy, the soil gets moisture
If it's sunny, the plant grows consuming moisture but dies if the soil is not moist
If it's cloudy, the soil just dries up a little
We should separate the domain logic from other concerns so that we can test it separately and keep it clear.
And this is how it's implemented:
// App.tsx
function App() {
// store
const clock = useClock()
const weather = useWeather()
const plant = usePlant()
const soil = useSoil()
// detect events
const event = useEvent(clock.time, soil.isMoist, weather)
// handle events
useConsumer(event, plant, soil)
// pass data into context
const ctxValue:AppData = {
weather: weather.currentWeather,
height: plant.height,
moisture: soil.moisture
}
return (
<div className="App">
<AppContext.Provider value={ctxValue}>
<div>Event Driven React</div>
<div>{clock.time}</div>
<div>weather: {Weather[weather.currentWeather]}</div>
<div>plant's height: {plant.height}</div>
<div>soil: {soil.moisture}</div>
<Plant />
</AppContext.Provider>
</div>
);
}
Since all the React states should be under components, we implement the service layer as custom hooks.
Besides, notice that we separated the event handling tasks into two.
First, we need to detect the events according to the store's states.
// services/event.ts
export const useEvent = (
time: ClockStore['time'],
isMoist: SoilStore['isMoist'],
weather: WeatherStore
) => {
const [event, setEvent] = useState<AppEvent>({timestamp: time, plantEvent: PlantEvent.Idle})
const { currentWeather, changeWeather } = weather
useEffect(() => {
changeWeather()
}, [time, changeWeather])
useEffect(() => {
if (time === event.timestamp) return
const evt = eventEmit(isMoist, currentWeather)
setEvent(
{timestamp: time, plantEvent: evt}
)
}, [isMoist, currentWeather, event.timestamp, time])
return event
}
export const eventEmit = (
isMoist: SoilStore['isMoist'],
currentWeather: WeatherStore['currentWeather']
):PlantEvent => {
switch (currentWeather) {
case Weather.sunny:
return isMoist
? PlantEvent.Grow
: PlantEvent.Die
case Weather.rainy:
return PlantEvent.Water
case Weather.cloudy:
default:
return PlantEvent.Dry
}
}
This event hook emits events every time new states are detected.
The function eventEmit
can be read exactly the same as the domain logic we mentioned earlier.
But it doesn't know what exactly to do to our "store". That task is delegated here:
// service/consume.ts
export const useConsumer = (
event: AppEvent,
{grow, die}: PlantStore,
{dry, water}: SoilStore,
) => {
useEffect(() => {
switch (event.plantEvent) {
case PlantEvent.Grow:
dry()
grow()
break
case PlantEvent.Water:
water()
break
case PlantEvent.Dry:
dry()
break
case PlantEvent.Die:
die()
break
}
}, [event, dry, water, grow, die])
}
So each PlantEvent
are implemented as the abstraction for domain logic.
Now, we can clearly see what this app is all about, by just looking at the service layer. It tells us the rule in a nutshell, doesn't it?
By using this pattern, it's much easier to test each event:
// consume.test.ts
import {useConsumer} from './consume'
import { renderHook } from '@testing-library/react'
import { AppEvent } from './event'
import { MockPlantStore, MockSoilStore } from '../__testing__/mocks'
import { PlantEvent } from './event'
describe("consume events", () => {
const EventBase:AppEvent = {
timestamp: '10:30:30',
plantEvent: PlantEvent.Grow
}
beforeEach(() => {
jest.resetAllMocks()
})
it("should consume moisture when plant grow", () => {
const mockEvent:AppEvent = {...EventBase}
renderHook(() => useConsumer(mockEvent, MockPlantStore, MockSoilStore))
expect(MockSoilStore.dry).toHaveBeenCalled()
expect(MockPlantStore.grow).toHaveBeenCalled()
})
it("should execute the manipulations according to each event", () => {
const waterEvent:AppEvent = {...EventBase, plantEvent: PlantEvent.Water}
renderHook(() => useConsumer(waterEvent, MockPlantStore, MockSoilStore))
expect(MockSoilStore.water).toHaveBeenCalled()
jest.resetAllMocks()
const dryEvent:AppEvent = {...EventBase, plantEvent: PlantEvent.Dry}
renderHook(() => useConsumer(dryEvent, MockPlantStore, MockSoilStore))
expect(MockSoilStore.dry).toHaveBeenCalled()
})
it("should not consume event again on the same event object", () => {
const dryEvent:AppEvent = {...EventBase, plantEvent: PlantEvent.Dry}
const { rerender } = renderHook(
({dryEvent}) => useConsumer(dryEvent, MockPlantStore, MockSoilStore),
{
initialProps: { dryEvent: dryEvent }
}
)
expect(MockSoilStore.dry).toHaveBeenCalledTimes(1)
rerender({dryEvent})
expect(MockSoilStore.dry).toHaveBeenCalledTimes(1)
})
it("should consume events if the event object renews", () => {
const dryEvent:AppEvent = {...EventBase, plantEvent: PlantEvent.Dry}
const { rerender } = renderHook(
({dryEvent}) => useConsumer(dryEvent, MockPlantStore, MockSoilStore),
{
initialProps: { dryEvent: dryEvent }
}
)
expect(MockSoilStore.dry).toHaveBeenCalledTimes(1)
rerender({dryEvent: {...dryEvent, timestamp: '10:30:31'}}) // timestamp changes every second
expect(MockSoilStore.dry).toHaveBeenCalledTimes(2)
})
})
Since the test file is live documentation, now it's clearer to know what it should do on events.
Recap
That's it. Remember that this is merely a small showcasing demo.
The best practice would depend on your project's requirements. In real projects, keep the following points in mind:
If a collection of state manipulation affects different states, it should be treated as an "app-wide event".
If the domain rule is about the domain model itself, it should be within "store" (i.e. state change methods, min/max configuration).
Always be humble. Consider if the responsibilities can be separated. Greedy hooks/components should be avoided.
Write tests to document rules, especially on domain logic.
UI components may query from stores, but it's not recommended to manipulate the store's state at UI components (in that case I would add
commands
in the service layer because it should be consistent across the entire app)Learning OOP practices might help your understanding of this architecture (that's how I came up with this event-driven pattern. but it might look different because of React environment)
Also, let me(@yozibak) know if there are any questions, improvements or suggestions on this architecture pattern:)