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:
The UseCase interface of
success
anderror
which, in my opinion, is all the presentation layer must know.Abstract this operation (fetch a list of
Products
) to reuse and easier maintainability. You can argue thatapi
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:
find the existing
user
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)
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!