Event-Driven Architecture in React

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.

dirty.png

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:

eda.png

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)

plantsim.png

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:)