← Back to Blog

SOLID Principles in React Native: Building a Component That Doesn't Bite You Later

2026-07-25·3 min read
ReactReact NativeTestingNetworkingDesign Patterns

SOLID is five rules: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion. In React they don't map to classes anymore since we mostly write functions, but the ideas map cleanly to components, hooks, and props. Let me walk through building one component, UserCard, and apply each letter to it as we go.

S: Single Responsibility

Start with the bad version. One component fetching data, formatting it, and rendering it:

function UserCard({ userId }) {
  const [user, setUser] = useState(null);
 
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => setUser(data));
  }, [userId]);
 
  if (!user) return <ActivityIndicator />;
 
  return (
    <View style={styles.card}>
      <Text>
        {user.firstName} {user.lastName}
      </Text>
      <Text>Joined {new Date(user.joinedAt).toLocaleDateString()}</Text>
    </View>
  );
}

This component has three jobs: fetching, formatting, rendering. Change the API shape and you touch this file. Change the date format and you touch this file. Split it:

function useUser(userId) {
  const [user, setUser] = useState(null);
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then(setUser);
  }, [userId]);
  return user;
}
 
function UserCard({ userId }) {
  const user = useUser(userId);
  if (!user) return <ActivityIndicator />;
 
  return (
    <View style={styles.card}>
      <Text>
        {user.firstName} {user.lastName}
      </Text>
      <Text>Joined {formatDate(user.joinedAt)}</Text>
    </View>
  );
}

Now useUser only fetches, formatDate only formats, UserCard only renders. One reason to change each one.

O: Open/Closed

A component should be open for extension, closed for modification. Meaning: adding a new use case shouldn't require editing the component's internals every time.

function UserCard({ userId, renderFooter }) {
  const user = useUser(userId);
  if (!user) return <ActivityIndicator />;
 
  return (
    <View style={styles.card}>
      <Text>
        {user.firstName} {user.lastName}
      </Text>
      {renderFooter ? renderFooter(user) : null}
    </View>
  );
}

Now a screen that needs a "message" button doesn't touch UserCard at all:

<UserCard userId={id} renderFooter={(user) => <MessageButton user={user} />} />

L: Liskov Substitution

If you have variants of a component, any variant should be swappable without breaking the parent. Say you make AdminUserCard that extends UserCard's behavior:

function AdminUserCard(props) {
  return (
    <UserCard {...props} renderFooter={(user) => <BanButton user={user} />} />
  );
}

Wherever <UserCard /> is used, <AdminUserCard /> should be droppable in without the parent screen needing special-case logic ("if admin, don't pass onPress" etc). If the parent has to know which variant it's holding, this principle is already broken.

I: Interface Segregation

Don't force a component to accept props it doesn't need. This one bit me on a real PR:

// bad: UserCard now needs to know about permissions, theme, AND analytics
function UserCard({ userId, permissions, theme, analyticsContext }) { ... }

If UserCard only renders name and join date, it shouldn't take permissions just because some other card in the same screen needs it. Give each component the smallest prop surface that does its job. Split into UserCard and AdminUserCard (as above) rather than one card with a mode flag and ten optional props.

D: Dependency Inversion

UserCard shouldn't know how the data over the network. It should depend on an abstraction, not fetch directly:

function UserCard({ userId, userRepository }) {
  const user = useUser(userId, userRepository);
  ...
}
 
function useUser(userId, repository) {
  const [user, setUser] = useState(null);
  useEffect(() => {
    repository.getUser(userId).then(setUser);
  }, [userId]);
  return user;
}

Now in tests you pass a fake repository, no network mocking needed:

const fakeRepo = {
  getUser: async () => ({ firstName: "Maya", lastName: "Dev" }),
};
render(<UserCard userId="1" userRepository={fakeRepo} />);

Putting it together

None of this means every component needs a hook, a repository interface, and a render prop from day one. That's over-engineering a <Text> label. Apply SOLID when a component starts doing more than one thing, or when you're about to copy-paste it for a slightly different screen. That's usually the signal it's time to split.