Advanced React TypeScript for Senior Engineers
Advanced React and TypeScript Concepts
Advanced Component Patterns
As your React applications grow, you'll find yourself needing to share logic between components. Simple prop drilling can become messy, and copying and pasting code is a recipe for bugs. To solve this, React developers have established several powerful patterns for reusing component logic. Let's explore three of the most common ones: Higher-Order Components, Render Props, and Custom Hooks.
Custom hooks allow you to extract and reuse logic across different components.
While all three patterns aim to make your code more reusable and maintainable, custom hooks have become the modern standard. They offer a more direct and less nested way to share logic compared to their predecessors. We'll start with the older patterns to understand the evolution of thought in React.
Higher-Order Components (HOCs)
A Higher-Order Component, or HOC, is a function that takes a component and returns a new component with enhanced functionality. Think of it as a wrapper. You give it a plain component, and it gives you back a super-powered version. HOCs are a way to reuse component logic without changing the component itself.
An HOC is a pure function with zero side-effects. It takes a component and returns a new component.
For example, let's create an HOC that provides window width information to any component that needs it. This is useful for creating responsive designs.
import React, { useState, useEffect } from 'react';
// The HOC function
function withWindowWidth(WrappedComponent) {
return function(props) {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Pass the new prop 'windowWidth' to the wrapped component
return <WrappedComponent {...props} windowWidth={width} />;
};
}
// A simple component that will use the HOC
const MyComponent = ({ windowWidth }) => {
return <div>The window width is: {windowWidth}px</div>;
};
// Enhance MyComponent with the HOC
const MyComponentWithWindowWidth = withWindowWidth(MyComponent);
export default MyComponentWithWindowWidth;
The withWindowWidth function wraps MyComponent and injects the windowWidth prop. The original MyComponent remains simple and unaware of how it gets this data. While powerful, HOCs can lead to "wrapper hell," where components are nested inside many layers of HOCs, making the code hard to read and debug.
Render Props
The render prop pattern offers another way to share logic. Instead of a function that returns a component (like an HOC), you use a component whose main job is to call a function passed to it as a prop. This function prop, often named render, tells the component what to render. This inverts the control, allowing the parent component to define the UI while the child component manages the state or logic.
import React, { useState, useEffect } from 'react';
// This component tracks mouse position
class MouseTracker extends React.Component {
constructor(props) {
super(props);
this.state = { x: 0, y: 0 };
}
handleMouseMove = (event) => {
this.setState({
x: event.clientX,
y: event.clientY
});
}
render() {
return (
<div style={{ height: '100vh' }} onMouseMove={this.handleMouseMove}>
{/* Call the render prop with the current state */}
{this.props.render(this.state)}
</div>
);
}
}
// Usage of the MouseTracker with a render prop
const App = () => (
<div>
<h1>Move the mouse around!</h1>
<MouseTracker render={({ x, y }) => (
<p>The mouse position is ({x}, {y})</p>
)} />
</div>
);
Here, MouseTracker handles the logic of tracking the mouse, but it doesn't decide how to display the coordinates. That's up to the function passed into its render prop. This pattern is more explicit than HOCs but can still lead to deep nesting in your JSX.
Custom Hooks
Introduced in React 16.8, hooks revolutionized how we write components. A custom hook is simply a JavaScript function whose name starts with "use" and that can call other hooks. It lets you extract component logic into reusable functions without changing your component hierarchy.
Hooks, introduced in React 16.8, allow functional components to use state and lifecycle features.
Let's refactor our HOC example into a custom hook. Notice how much cleaner the implementation and usage become.
import { useState, useEffect } from 'react';
// The custom hook
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
// Cleanup function
return () => window.removeEventListener('resize', handleResize);
}, []); // Empty dependency array means this effect runs once on mount
return width;
}
// The component using the hook
const MyComponent = () => {
const windowWidth = useWindowWidth();
return <div>The window width is: {windowWidth}px</div>;
};
export default MyComponent;
With useWindowWidth, the logic is neatly contained, and any functional component can use it with a single line of code. There's no extra nesting or prop passing. This clarity and simplicity are why custom hooks are the preferred method for sharing logic in modern React.
Advanced TypeScript
While basic types are great, TypeScript's real power comes from its advanced features. These tools help you create flexible, reusable, and highly type-safe components and functions. We'll look at three key concepts: generics, utility types, and discriminated unions.
mastering advanced TypeScript concepts can significantly enhance your ability to write robust, maintainable, and scalable code.
Let's start with Generics, which allow you to write functions and classes that can work with any data type.
Generic
adjective
In programming, a generic is a feature that allows algorithms to be written in terms of types that are specified later. This enables writing common functions or classes that can operate on various data types without sacrificing type safety.
Imagine you need a function that returns the first element of an array. The array could contain numbers, strings, or objects. Without generics, you might use the any type, but that loses all type information.
// Using 'any' (loses type safety)
function getFirstElementAny(arr: any[]): any {
return arr[0];
}
// Using Generics
function getFirstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
const numbers = [1, 2, 3];
const firstNum = getFirstElement(numbers); // Type is inferred as 'number'
const strings = ["a", "b", "c"];
const firstStr = getFirstElement(strings); // Type is inferred as 'string'
Here, T is a type variable. It captures whatever type the array contains and uses it as the function's return type. This makes your function both flexible and type-safe.
Utility Types and Discriminated Unions
TypeScript comes with several built-in utility types that help you transform existing types. They are like functions for your types. Some of the most common ones are Partial<T>, Readonly<T>, Pick<T, K>, and Omit<T, K>.
| Utility Type | Description |
|---|---|
Partial<T> | Makes all properties of T optional. |
Readonly<T> | Makes all properties of T read-only. |
Pick<T, K> | Creates a new type by picking a set of properties K from T. |
Omit<T, K> | Creates a new type by removing a set of properties K from T. |
These are incredibly useful for creating variations of your types without rewriting them from scratch. For example, when updating a user profile, you might only send the fields that changed. Partial<User> is perfect for this.
interface User {
id: number;
name: string;
email: string;
}
function updateUser(id: number, update: Partial<User>) {
// ... function logic
}
// Valid call:
updateUser(1, { name: 'New Name' });
Finally, discriminated unions are a powerful pattern for handling objects that can have different shapes. The key is a shared property, the "discriminant," which TypeScript can use to narrow down the object's type.
Imagine a system that handles different shapes. Each shape has a kind property that tells us what it is.
interface Square {
kind: "square";
size: number;
}
interface Circle {
kind: "circle";
radius: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
type Shape = Square | Circle | Rectangle;
function getArea(shape: Shape): number {
switch (shape.kind) {
case "square":
return shape.size * shape.size; // TS knows 'shape' is a Square
case "circle":
return Math.PI * shape.radius ** 2; // TS knows 'shape' is a Circle
case "rectangle":
return shape.width * shape.height; // TS knows 'shape' is a Rectangle
}
}
Inside the switch statement, TypeScript understands that if shape.kind is "square", then shape must be of type Square. This allows you to access shape.size safely. This pattern makes your code cleaner and safer than checking for properties with if ('size' in shape).
Ready to test your knowledge on these advanced concepts? Let's see what you've learned.
What is the primary problem that Higher-Order Components, Render Props, and Custom Hooks all aim to solve in React?
What is a potential downside of using Higher-Order Components (HOCs) extensively in a React application?
By mastering these advanced patterns and types, you're well-equipped to build complex, scalable, and maintainable applications with React and TypeScript.