UseCases in React - Anatomy

UseCases in React - Anatomy

In the first article, I introduced the UseCases and its advantages. Now let me show you how it looks from the inside and how I use it from my components.

Success and Error

You remember that a UseCase is an async function that returns an object containing success and error. These two object properties are always there. However, the difference is that one is truly while the other is falsy.

To have this, I define these two functions to be used on every UseCase:

const useCaseSuccess = <T>(returnData: T) => ({
  success: returnData,
  error: null
})

const useCaseError = (e: ErrorType) => ({
  success: null,
  error: e
})

Ok, but how it works? Check these two examples below.

Simple example

This is an example of a simple UseCase where I get a list of Products by vendor id:

const getProductsUseCase = 
  async (vendorId: string): UseCaseReturnType<Products[]> => {
    try {
      const products = await api.getProducts(vendorId)
      return useCaseSuccess(products)
    } catch (e) {
      return useCaseError(e)
    }
  }

For now, It does not matter what api is or where it comes from. In a future article, I will show how I manage dependencies.

In this simple example, you can see that:

  • An UseCase is an async function that receives parameters or not.

  • It has a return type that defines what to expect if everything went well. Typescript will ensure you return the correct data type.

  • Its content is wrapped by a try-catch block that helps me return error if something went wrong.

  • It is made by one or more operations inside it.

This is what a simple UseCase looks like. Using this instead of a direct api call right inside our component gives us a few advantages:

  1. The UseCase interface of success and error which, in my opinion, is all the presentation layer must know.

  2. Abstract this operation (fetch a list of Products) to reuse and easier maintainability. You can argue that api is already enough abstraction. For this simple example, I can not disagree. Below, we will see a more complex example where the UseCase as an abstraction makes sense.

And this is the code I would use on my component:

const fetchProducts = async () => {
  setLoading(true) // local state
  const { success, error } = await getProductsUseCase(vendorId)
  setLoading(false)
  if (error) {
    // handle error somehow
    return // interrupt
  }
  success && setProductsList(success) // local state
}

useEffect(() => {  
  fetchProducts()  
}, [])

I define a function (useCallback maybe) and execute it inside a useEffect to fetch data at component render.

This way I have control on:

  • Loading. It's up to me to decide where it starts and where it ends.

  • Error. Avoid app crashing and handle it.

  • Refetch. If I want to give the user the option to refresh the list or retry after a failure, I just need to execute my function again.

You can argue that is too much code inside the React component only for a single and simple fetch. However, all this code are just necessary to manage presentation (loading, error, refetch and the actual data). My component knows 0% of what happens to get this list back. This is awesome. You can even reuse this UseCase code on a different Javascript UI library.

Less simple example

Now, one of the biggest advantages of UseCases in my opinion is to hold flows containing dependencies and business rules.

Let's say we are working on a social authentication feature. Let's keep it simple and consider only Google here.

In our system, users have unique emails. The user can use his unique email to sign in by Password or by Google (OAuth). From the user's point of view, this is very convenient.

To guarantee a smooth experience we need to handle some scenarios:

  • A completely new user sign-up using a password

  • A completely new user authenticates using Google

  • A user already signed up with a password, now authenticates with Google

  • A user already signed up with Google, now signs up by password

These flows have common steps like checking for unique emails, confirming user email, create a new record in our users table and so on. For points 3 and 4, however, we also need to:

  1. find the existing user

  2. confirm that the same person is trying to add a new sign-in method (by asking him/her to sign in with the existing method)

  3. update user record to accept the new sign-in method

This is how the authenticateWithGoogleUseCase would look like from the inside:


const authenticateWithGoogleUseCase = async (): UseCaseReturnType<User> => {
  try {
    const googleUserData = await auth.GoogleAuthentication()
    const { user: { email, name }, idToken } = googleUserData

    if (!email) throw new Error('No e-mail found')

    const isRegisteredUser = await auth.isRegisteredUser(email)

    if (isRegisteredUser) {

      const alreadySignedWithGoogle = auth.isSignedWithGoogle(email)

      if (alreadySignedWithGoogle) {
        const user = await auth.getUser()
        return useCaseSuccess(user)
      } 

      if (!alreadySignedWithGoogle) {
        const typedPassword = await openPrompt['password']({
          promptTitle: 'Type your password',
          promptMessage: 'This email is already in use. Type your password so we can merge your already existing account'
        })
        const user = await auth.signInWithEmailAndPassword(email, typedPassword)
        const updatedUser = await auth.mergeSignInMethods(user)
        return useCaseSuccess(updatedUser)
      }
    } 

    const user = await db.createUser(email, name)
    return useCaseSuccess(user)

  } catch (e) {
    console.log('[Google Social Auth]: Error')
    console.log(e)
    return useCaseError(e as Error)
  }
}

OBS: This UseCase has three dependencies (auth, db and openPrompt) which management I talk more about in the third article of this series.

With this example I hope is clear how a more complex flow fits well on a UseCase. My component knows absolutely nothing of what is going on when this function is executed. It just expects success and error. This means my component can receive any function that respects the UseCase signature (did someone say unit tests?). On the other side, this uncoupled function can be reused where it is needed, in other components for instance.

Let me know what you think.

In the next article, I talk about the UseCase dependencies.

See you!