No State Means Almost No Internal Dependency

Lego bricks that make shapes

Thanks to Yehonathan Sharvit and Bob Martin for this idea.

State is why apps are so complex.

When state is in multiple objects, you have to manage them:

  • Dependency injection
  • Service containers
  • Or put all objects in globals

But what if there was no state, and instead…

There was a single data map for the whole app?

There’d be almost nothing to manage.

Just functions calling functions.

There’d be no almost no architecture or internal dependency. 1

How do you do this?

Below are 3 approaches to the same problem.

The 1st and 2nd have state and dependencies.

The 3rd has no state, and almost no dependency.

We’ll use an example of a React app where on logging in, it redirects you to the home page.

Approach 1: React hooks

The authentication hook, useAuth(), needs to call the router hook, useRouter(), to redirect to the home page.

To do this, on line 3, we’ll inject router to useAuth(). 2

const router = useRouter()
// Inject router as a dependency of useAuth().
const logIn = useAuth(router)

function useRouter() {
  const [route, setRoute] = useState('')

  return {
    goTo(route) {
      setRoute(route)
    },
    route,
  },
}

function useAuth(router) {
  const [isLoggedIn, setIsLoggedIn] = useState('')
  return {
    logIn() {
      if (/* success */) {
        setIsLoggedIn(true)
        router.goTo('home')
      }
    },
    isLoggedIn,
  }
}

Code language: JavaScript (javascript)

We had to inject router because it has state.

On line 22, useAuth() called router.goTo('home').

Approach 2: Object-oriented programming

With OOP, you could do the same thing, but with a more traditional dependency injection.3

const router = new Router()
// Inject router into Auth.
const auth = new Auth(router)

class Router {
  route = ''
  goToRoute(route) {
    this.route = route
  }
}

class Auth {
  // Pass Router as a dependency of Auth.
  constructor(router) {
    this.router = router
    this.isLoggedIn = false
  }

  logIn() {
    if (/* success */) {
      this.isLoggedIn = true
      this.router.goTo('home')
    }
  }
}
Code language: JavaScript (javascript)

What’s wrong with these approaches?

I couldn’t figure out how to test Approach 1.

You can only test React hooks in a React component.

So even testing a single hook requires a workaround.

But I couldn’t test multiple hooks like in this example.4

Approach 2 is much better.

You can unit test it separately from React components.

But a large app will have a large dependency graph.

Classes that depend on classes that depend on classes…

Approach 3: Single data map, almost no dependency

This puts the entire app data in a single object:

{
  isLoggedIn: false,
  route: 'login',
  messages: [],
  ...
}Code language: JavaScript (javascript)

To update the app data, you add a reducer to actions:

export function makeReducers(
  httpGateway,
  routerGateway
) {
  return async (state, action) => {
    const actions = {
      'LOG_IN': logIn,
      'UPDATE_ROUTE': updateRoute,
      ...
    }

    return validateSchema(
      await actions[action.type](state, action),
      initialState
    )
  }
}
Code language: JavaScript (javascript)

…and you call that reducer in the React component like:

dispatch({ type: 'LOG_IN' })

Here are the reducers. They’re pure functions:

function updateRoute(state, action) {
  return {
    ...state,
    route: action.payload.route,
  }
}

function logIn(state) {
  const success = true // Hard-coded for simplicity.
  const newState = {
    ...state,
    isLoggedIn: success,
  }

  // Simply calls updateRoute() and passes it the state.
  // updateRoute() is not a dependency.
  return success
    ? updateRoute(
        newState,
        {
          payload: {
            route: 'home',
          }
        }
      )
    : newState
}
Code language: JavaScript (javascript)

Unlike before, logIn() has no dependency on updateRoute().

It simply calls it.

To show how independent those functions are…

Here’s what logIn()would look like if it didn’t call updateRoute():

function logIn(state) {
  const success = true // Hard-coded for simplicity.
  const newState = {
    ...state,
    isLoggedIn: success,
  }

  return newState
}Code language: JavaScript (javascript)

Here’s logIn() when it does call udpateRoute().

The only difference is on lines 8-16:

function logIn(state) {
  const success = true // Hard-coded for simplicity.
  const newState = {
    ...state,
    isLoggedIn: success,
  }

  return success
    ? updateRoute(
        newState,
        {
          payload: {
            newRoute: 'home',
          }
        }
      )
    : newState
}
Code language: JavaScript (javascript)

It’s just a function calling a function.

The reason the previous 2 approaches had dependencies is that the router had state.

So this had to pass the router to the login code, in order to mutate its state.

Here, updateRoute() is a pure function that returns the new app data.

Instead of calling updateRoute(), you could copy-paste the app data it returns. It’d be the same thing.

So logIn() and updateRoute() don’t have a dependency relationship.

They have a composition relationship.

No state means almost no dependency.

No architecture diagram, no service container config.

OK, but how hard is it to make another change?

It’s easy.

Let’s say you wanted to render a success notice on logging in.

Message state is something most of the app would use.

So with traditional React state, we might have to inject messages:

const router = useRouter(routerGateway)
const messages = useMessages()
const logIn = useAuth(router, messages)
Code language: JavaScript (javascript)

But with the stateless approach, all we have to do is add to the messages array:

function logIn(state) {
  const success = true // Hard-coded for simplicity.
  const newState = {
    ...state,
    isLoggedIn: success,
  }

  return success
    ? updateRoute(
        {
          ...newState,
          messages: [
            ...state.messages,
            'You are logged in!',
          ],
        },
        {
          payload: {
            newRoute: 'home',
          }
        }
      )
      : newState
}
Code language: JavaScript (javascript)

Or if you’d prefer to add a message with a function:

  return success
    ? updateRoute(
        addMessage(     Code language: JavaScript (javascript)

And addMessage() isn’t a dependency.

It has no state, it simply returns data.

What’s the API to update the app data?

It’s similar to Redux.

In the React component, you call dispatch():

function LoginComponent() {
  const { dispatch, state } = useAppContext()

  return (
    <SomeComponent
      onClick={() => {
        dispatch({
          type: 'LOG_IN',
          payload: { email, password }
        })
      }}
    />
  )
}
Code language: JavaScript (javascript)

This is available in the entire app via the useContext() API:

function useAsync(initial) {
  const [state, updateState] = useState(initial)
  const reducer = makeReducers(new HttpGateway(), new RouterGateway())

  return {
    state,
    dispatch: async (action) => {
      updateState(await reducer(state, action))
    }
  }
}

function App() {
  return (
    <AppContext.Provider value={useAsync(initialState)}>
      <AppComponent />
    </AppContext.Provider>
  )
}Code language: JavaScript (javascript)

But what about encapsulation?

There’s no encapsulation in the OOP sense.

But this is safer than OOP from unexpected state change.

Because these are basically pure functions,5 you can validate the state’s schema after every state update:

  return validateSchema(
    await actions[action.type](state, action),
    initialState
  )Code language: JavaScript (javascript)

So let’s say in a state reducer, you forgot to spread in the state:

function toggleAuthorList(state) {
  return {
     // Forgot this: ...state,
    isAuthorListToggledOn: !state.isAuthorListToggledOn,
  }
}Code language: JavaScript (javascript)

This would mistakenly remove the entire app state, other than isAuthorListToggledOn.

But it would make a unit test fail, so you’d catch it:

No internal dependency test

So it’s:

  1. Easy to test
  2. Easy to change (call any function in any function)

Why did you say “almost” no internal dependency?

Because this still depends on gateways.

Gateways connect application code to the outside world.

Like HTTP endpoints and databases.

For example, logging in would probably involve an HTTP request.

In production code, there would be a gateway that makes real HTTP requests.

And in tests, the gateway would be a spy. It wouldn’t make a real request.

None of the 3 approaches above have a gateway, but a real project should.

Here’s how you inject gateways in the demo repo.

Toy with twisting pipes

State creates complexity

State’s real cost is that you have to organize it.

When there’s only 1 data object in the app…

There’s very little to organize.

  1. This works best for apps with a lot of data, like React apps, and it doesn’t work well for integration apps, because they’ll have a lot of gateways.[]
  2. This could avoid injection by having a single hook for the entire app, but that would have the same problem of being hard to test.[]
  3. This would probably use MobX, but this simple example doesn’t use a state library.[]
  4. In fairness, testing multiple state objects would be just as hard, though I wouldn’t recommend that.[]
  5. They are pure functions in these examples, but in a real project, they would have gateways that run side-effects.[]

2 responses to “No State Means Almost No Internal Dependency”

  1. Ryan Kienstra Avatar

    Unit tests are really easy with this approach. There’s very little setup, and there’s always a new state object to assert.

  2. Ryan Kienstra Avatar

    Even with Approach 3 (no state), it’s fine to have mutable presentation state. For example, some form <input> elements might have state like [input, setInput] = useState().

    As long as the app data stays in the immutable state map.

Leave a Reply

Your email address will not be published. Required fields are marked *