One of the biggest advantages of Object Oriented Programming (OOP) is the ability to use Dependency Inversion. Meaning we get to depend on an abstract set of rules rather than a concrete implementation of those rules.
Using TypeScript, we can define an interface, with all the rules we want our objects to satisfy:
export interface IPeopleRepository {
findByName(name: string): Promise<IPerson[]>;
insert(person: IPerson): Promise<void>;
}
We use the interface to define an abstract IPeopleRepository. In order for an object to be a valid IPeopleRepository all it needs to do is implement both methods, findByName and insert.
Covering the repository pattern is out in the scope of this blog post, but feel free to take a look at this other blog post where I explain it in detail 😁
The idea behind this, rather than just implementing IPeopleRepository directly as a class, is that our code will depend on the interface (IPeopleRepository) rather than the actual implementation. That way, we can easily replace the implementation later on.
That idea is key. Not only for making testing easier and writing solid software, but also to kickstart initial development.
Example: An API we still don’t have access to
Imagine an app that needs access to a special “People API”. The problem being, we still don’t have access to that API. What we do have though, is what that API will return. Not all of it, but the parts that we need.
Can we start implementing some features while the API is finalized? The answer is yes. And as you might have guessed, we’ll do some mocking.
Because our code will depend on the abstraction (interface), not the implementation, the plan is to:
- Define an interface with the data we need from the API
- Implement that interface with a mock class
- Once the API is in place, we can replace the mock implementation with the real one
We already have an interface for step 1, so the next step is creating a mock repository class, implementing that interface:
For simplicity purposes I skipped the Person class but for now we can assume all we need from a person are name and age.
With a repository in place, we can start using it in our app. Because we’ll be using React, we’ll create a custom hook called usePeopleByName to be able to access our repository in a React-friendly way.
# ./hooks/people.ts import MockPeopleRepository from './repositories/mocks/people.ts' const peopleRepository: PeopleRepository = new MockPeopleRepository();
The first step is to import our mock repository and create a new instance. It’s important that peopleRepository is of the interface type (PeopleRepository) and not the implementation type (MockPeopleRepository). So we make sure to set the type explicitly.
With that in place, we can start writing our custom hook:
export const usePeopleByName = (name: string) => {
const [people, setPeople] = useState<Person[] | null>(null);
useEffect(() => {
peopleRepository
.findByName(name)
.then((people) => {
setPeople(people);
})
.catch((error) => {
throw error;
});
}, [name]);
return people;
};
A pretty simple implementation that gets the job done. We can import this hook in any component and use it as such:
export function MyComponent({ name }: { name: string }) {
const people = usePeopleByName(name);
if (people === null) return <Loader />
return <div>
<h1>People</h1>
{people.map((person) =>
return <div>Name: {person.name}</div>
)}
</div>
}
Simulating Delay
a
Invalidating Hooks
That might work just fine for some use cases, but what happens when we add a new person, or we change a person? The results of our hook should also change.
Using React Query or SWR, we’d invalidate the endpoint we use to fetch people, and that would do it, but now we abstracted away the implementation. Our repository could use an API, but not necessarily.
The good news is that we have another pattern that can help: the Observer pattern (or PubSub). We’ll use an EventEmitter to emit events and listen to those events to update the hooks accordingly.
I’ll use eventemitter3 to have a high performance event emitter object. We then need to:
- Inject an event emitter instance into our repository
- Have the repository trigger proper events when something changes
- Listen for those events in our hooks using the event emitter instance
The first step is easy enough, and to demonstrate it, I’ll need to create an insert method. I’ll start by adding that new method to the interface:
interface PeopleRepository {
findByName(name: string): Promise<Person | null>;
insert(person: Person): Promise<void>;
}
And now updating the repository:
class MockPeopleRepository implements PeopleRepository {
async findByName (name: string) {
return new Person({ name, age: 25 });
}
async insert(person: Person) {
// TODO
}
}
Replacing the mock object
Let’s say the API is now ready. What would be the next step to use it in our app? Well, it’s actually rather simple! All we need to do is implement an the interface in a new class, and use the API to satisfy the methods, rather than doing it in-memory:
class APIPeopleRepository implements PeopleRepository {
async findByName (name: string) {
const response = await fetch(`${ENDPOINT}/people/find?name=${name}`);
const json = await response.json() as People[];
return json;
}
async insert(person: Person) {
await fetch('');
this.emitter.emit('people:changed');
}
}
Now all we have to do is replace the object instance with our new repository, and our app should “just work”!
# ./hooks/people.ts
import APIPeopleRepository from './repositories/people.ts'
const peopleRepository: PeopleRepository = new APIPeopleRepository();
We could even choose to only use the API repository in development:
# ./hooks/people.ts
import MockPeopleRepository from './repositories/mocks/people.ts'
import APIPeopleRepository from './repositories/people.ts'
const peopleRepository: PeopleRepository = process.env.NODE_ENV === 'development' ? new MockPeopleRepository() : new APIPeopleRepository();
Testing
Because we already have a mock repository, we can use that repository to test our components against, if we ever need to write unit tests for components using our hooks. No need to install a library to hijack API calls or fetch, we did it with actual objects.
It’s important to note though that if we wanted to test the actual API repository we would need to mock the API or actually hit the live API when running our tests. Normally these are not tested because it’s considered a third-party dependency. Instead, it’s enough to test that when the API breaks your app won’t break, and can handle the error gracefully.
Conclusion
a