Back to Articles
TypeScript

TypeScript with React: Practical Patterns for Real-World Applications

TypeScript can do much more than provide autocomplete in a React application. Here's how I use types for component props, API responses, UI states, forms, reusable logic and safer refactoring—and where I think developers can go too far with it.

Jorris NyangeSeptember 17, 202610 min read
TypeScript with React: Practical Patterns for Real-World Applications

When I first started using TypeScript with React, I thought the main benefit was getting better autocomplete.

It was useful, of course.

I could type something, and my editor would immediately suggest properties and methods.

But the more I used TypeScript in actual applications, the more I realised that autocomplete is only a small part of the story.

The bigger benefit is having a clearer understanding of what my application expects.

What does this component receive?

What does the API return?

Which values are optional?

What happens when a value is missing?

What type of data can this function accept?

Those questions become much easier to reason about when the code itself describes the expected data.

That's where TypeScript has become particularly useful to me when working with React.

React Works Fine Without TypeScript

Before going further, it's worth saying this:

You don't need TypeScript to build React applications.

JavaScript is perfectly capable of doing it.

React was built to work with JavaScript, and there are plenty of successful applications written entirely in JavaScript.

So I don't see TypeScript as something that automatically makes a project better.

I see it as a tool that becomes increasingly valuable as an application grows in complexity.

The more components, developers, APIs and business rules an application has, the more useful it becomes to have explicit contracts between different parts of the codebase.

Start With Component Props

One of the simplest places to use TypeScript in React is component props.

Without types, you might have:

hljs tsx
function UserCard({ name, email, role }) { // ... }

There's nothing immediately telling another developer what those values are supposed to be.

With TypeScript:

hljs tsx
interface UserCardProps { name: string; email: string; role: string; } function UserCard({ name, email, role }: UserCardProps) { // ... }

Now the component has a clear contract.

It expects:

  • name to be a string
  • email to be a string
  • role to be a string

If someone passes the wrong value, TypeScript can flag it before the application reaches the user.

That might seem like a small improvement.

It is.

But small improvements like this become valuable when repeated throughout a large application.

Don't Type Everything Just for the Sake of Typing

There is also a temptation when learning TypeScript to add types everywhere.

You end up writing interfaces for every tiny object and creating complicated generic types for simple functions.

That can make code harder to read.

The goal isn't:

"How much TypeScript can I add?"

The goal is:

"Where does having an explicit type make the code safer or easier to understand?"

TypeScript should reduce uncertainty, not create unnecessary ceremony.

API Responses Are Where TypeScript Really Starts Paying Off

One of the areas where I find TypeScript particularly useful is when working with APIs.

Imagine an API returns:

hljs json
{ "id": "123", "name": "John Doe", "email": "john@example.com", "role": "admin" }

On the frontend, I can describe that structure:

hljs ts
interface User { id: string; name: string; email: string; role: string; }

Now when I'm working with a User, I know what I'm dealing with.

For example:

hljs ts
function displayUser(user: User) { console.log(user.name); }

If I accidentally write:

hljs ts
console.log(user.username);

TypeScript can tell me that username isn't part of the User type.

This becomes especially useful when frontend and backend development happen separately.

The types become a kind of shared understanding of the data.

They don't replace API documentation or runtime validation, but they make the frontend code much easier to reason about.

Types Don't Validate Runtime Data

This is an important distinction.

TypeScript helps during development.

It doesn't magically validate data coming from a server at runtime.

For example, you might define:

hljs ts
interface User { id: string; name: string; }

That doesn't guarantee that the backend will actually send a valid User.

The API could return:

hljs json
{ "id": 123, "name": null }

and your TypeScript interface won't physically stop that response from arriving.

This is why applications that deal with unreliable external data may also need runtime validation.

TypeScript describes what you expect.

Runtime validation checks what you actually received.

Understanding that difference has helped me avoid treating TypeScript as a replacement for proper application validation.

Optional Properties Matter

Real applications rarely have data where every field is guaranteed to exist.

Consider a user profile:

hljs ts
interface User { id: string; name: string; avatar?: string; }

The ? means avatar may not exist.

That forces me to think about the UI.

What should happen if the user doesn't have an avatar?

Instead of discovering the problem later through a broken image or unexpected behaviour, the type reminds me that the value isn't guaranteed.

For example:

hljs tsx
<img src={user.avatar ?? "/default-avatar.png"} alt={user.name} />

Now the component has an explicit fallback.

That's one of the things I like about TypeScript.

It often turns hidden assumptions into visible decisions.

Union Types Are Useful for UI States

React applications frequently have different states.

A request might be:

  • Loading
  • Successful
  • Failed

Instead of representing everything with unrelated booleans, TypeScript can help describe those states.

For example:

hljs ts
type RequestStatus = | "idle" | "loading" | "success" | "error";

Then:

hljs ts
const [status, setStatus] = useState<RequestStatus>("idle");

Now status can only be one of the defined values.

You can't accidentally set:

hljs ts
setStatus("complete");

when "complete" isn't part of the application's defined state.

This is particularly useful for interfaces with multiple UI states.

Forms Are Another Good Example

Forms can become complicated quickly.

You might have:

  • Text inputs
  • Select fields
  • Checkboxes
  • Dates
  • Numbers
  • Optional fields
  • Validation
  • Submission states

TypeScript can help define what the form actually represents.

For example:

hljs ts
interface RegistrationForm { firstName: string; lastName: string; email: string; phone: string; agreeToTerms: boolean; }

Now different parts of the application can work with the same structure.

The form component knows what it produces.

The submission function knows what it expects.

And other developers don't have to guess which fields are available.

Avoid any When You Can

One of the easiest ways to defeat the purpose of TypeScript is to use any whenever something becomes difficult.

hljs ts
const user: any = data;

Problem solved.

Except it isn't.

You've basically told TypeScript:

"Don't check this."

There are legitimate situations where any may be necessary, especially when dealing with poorly typed third-party code or legacy systems.

But I try not to use it as the default solution.

If I don't know what something is, I'd rather investigate the data and define an appropriate type.

Sometimes unknown is also a better choice when the type genuinely isn't known yet.

The important thing is not to pretend that untyped data is safe.

Reuse Types Carefully

As applications grow, you'll often find similar types appearing in multiple places.

For example:

hljs ts
interface User { id: string; name: string; email: string; }

It might be used by a table, profile page and API service.

Sharing the type can be useful.

But I also try to avoid creating one enormous type that represents everything about an entity.

For example, a User returned from an API might contain 30 fields, while a dropdown only needs:

hljs ts
type UserOption = Pick<User, "id" | "name">;

This makes the requirement more explicit.

The important thing is to reuse types where they represent the same concept—not simply because two objects happen to look similar today.

Type Your Functions Too

Functions are another area where explicit types can make intent clearer.

For example:

hljs ts
function calculateTotal( price: number, quantity: number ): number { return price * quantity; }

Now we know exactly what the function accepts and what it returns.

If someone later tries:

hljs ts
calculateTotal("100", 2);

TypeScript catches the problem.

This becomes increasingly useful when functions are shared across different parts of an application.

TypeScript Makes Refactoring Safer

This is probably one of my favourite benefits.

Imagine an application has dozens of components using a particular property:

hljs ts
user.name

Later, the backend changes and the frontend model becomes:

hljs ts
user.fullName

If the application is properly typed, changing the type can reveal many of the places that need to be updated.

Without that type information, you may have to rely much more heavily on searching the codebase and testing everything manually.

TypeScript doesn't eliminate bugs.

But it can make certain classes of mistakes much easier to find.

Types Can Improve Team Communication

When you're working on a project with multiple developers, communication becomes part of software development.

You shouldn't have to constantly ask:

"What does this API return?"

or:

"What properties does this component expect?"

Good documentation still matters, but well-defined types provide useful information directly inside the code.

A developer can open a function or component and immediately see its expected inputs and outputs.

That can make onboarding and collaboration easier.

Don't Let Types Take Over the Codebase

There's a balance here.

I've seen TypeScript code become so complicated that the types themselves are harder to understand than the application.

Something like this:

hljs ts
type Something< T extends Record<string, unknown>, K extends keyof T, R extends Partial<Record<K, unknown>> > = ...

might be completely justified in some situations.

But sometimes a simpler solution would be better.

I try to follow a fairly simple principle:

If the type makes the code harder to understand than the problem itself, reconsider the abstraction.

TypeScript is there to help us understand software.

Not to impress us with how clever the types can be.

TypeScript Doesn't Replace Good JavaScript

This is another important lesson.

TypeScript won't automatically make someone a better JavaScript developer.

You still need to understand:

  • Objects
  • Arrays
  • Functions
  • Closures
  • Promises
  • Async/await
  • Modules
  • Events
  • Browser behaviour
  • React's rendering model

TypeScript sits on top of those concepts.

If the JavaScript fundamentals aren't clear, TypeScript can sometimes hide the underlying problem rather than solve it.

For me, the best approach is to understand the JavaScript first and then use TypeScript to make the code more predictable.

How I Think About TypeScript Today

I don't use TypeScript because it's fashionable.

I use it because, once an application becomes large enough, I don't want to rely entirely on memory and assumptions.

I want the codebase to tell me things.

What does this component need?

What does this function return?

Which properties are optional?

What values are allowed?

Where does this data come from?

What might break if I change this?

Those answers become much easier to see when types are used thoughtfully.

And that's probably the biggest change in how I think about TypeScript.

At first, I saw it as a developer productivity tool.

Now I see it more as a communication layer inside the codebase.

It communicates intent between components.

Between frontend and backend.

Between developers.

And sometimes, most importantly, between the developer I am today and the developer who has to maintain the code six months from now.

Final Thoughts

TypeScript doesn't eliminate bugs.

It doesn't replace testing.

It doesn't validate API responses at runtime.

And it certainly doesn't make poor architecture disappear.

But when used well, it can make a React application easier to understand, refactor and maintain.

For me, that's where its real value is.

Not in writing more types.

Not in avoiding every possible TypeScript error.

But in making the assumptions inside an application more visible.

Good TypeScript isn't about typing everything. It's about making the important things clear.

Share this article

Found this helpful? Share it with your network.

Newsletter

Enjoyed This Article?

Subscribe to get more insights like this delivered straight to your inbox. No spam, unsubscribe anytime.

By subscribing, you agree to our Privacy Policy.