Anti-Patterns
Introduction
The following is a list of common React Native anti-patterns that developers tend to fall into. The goal of this section is to identify those anti-patterns, explain why they can cause issues, and alternatives that allow developers to accomplish their intentions with best practices.
Passing objects or array literals as props to memoised components
When a component is declared using React.memo
, React will perform a shallow equality check each render to compare the props of a component, and determine if it needs to be re-rendered. As a Javascript developer, most people will know that primitives are compared by value, meaning the following will evaluate to true:
const a = "string";
const b = "string";
a === b; // true
This means that a memoised component receiving primitive props, that haven't changed between renders, will not re-render:
const ParentComponent = () => {
return <MemoisedComponent firstProp="prop" secondProp="prop" />;
};
const MemoisedComponent = React.memo((props) => {
return (
<View>
<Text>
{props.firstProp} {props.secondProp}
</Text>
</View>
);
});
In this example React is performing a shallow equality check on firstProp
and secondProp
which will evaluate to true, skipping the re-render.
Where this can trip developers up is when passing objects or arrays to memoised components. Because Javascript compares objects (in JS arrays are considered objects) by reference, meaning two visually identical objects won't be considered equal.
const a = { value: "hello" };
const b = { value: "hello" };
a === b; // false
This is because a
and b
both have different addresses in memory, and JS will compare those addresses to determine equality. This means passing object and array literals to memoised components will defeat that memoisation because those props will be recreated each render, giving them a different memory address and in JS a different value to compare.
const ParentComponent = () => {
return (
<MemoisedComponent firstProp={[1, 2, 3]} secondProp={{ value: "hello" }} />
);
};
const MemoisedComponent = React.memo((props) => {
return (
<View>
<Text>
{props.firstProp} {props.secondProp}
</Text>
</View>
);
});
In this example React is comparing the previous and next value of firstProp
and secondProp
and deciding they are different, causing a re-render.
This means not only is the component still re-rendering for each render of the parent, we are wearing the cost of using React.memo
without any of the benefits.
For this reason it's important to be conscious of what props you are passing to memoised components.
Solution
If you have decided that a component should be memoised, then any non-primitive values need to have a referentially stable memory address between renders. This can be achieved by either:
- Declaring any constants or values not dependant on component state in the outer scope of the file. This method should be the first thing you reach for because it doesn't come with the overhead of
useMemo
. This will guarantee the value has the same reference between renders.
const firstProp = [1, 2, 3];
const secondProp = { value: "hello" };
const ParentComponent = () => {
return <MemoisedComponent firstProp={firstProp} secondProp={secondProp} />;
};
- Declaring the value with the
useMemo
hook. It is important to properly set the dependecies, as failing to do so will mean the value returned byuseMemo
isn't as referentially stable as it should be which can defeat the component memoisation.
Passing arrow functions as props to memoised components
This anti-pattern isn't too different from the previous. In the following code snippet, the arrow function will be re-created each render, this means it will have a different reference each render and defeat the memoisation of the receiving component.
const ParentComponent = () => {
return <MemoisedComponent onPress={() => runFunction()} />;
};
When React goes to compare the new and old props it will think it needs to re-render because the identity of the onPress
prop has changed.
Solution
- Declare functions that don't require access to component state in the outer scope.
const onPress = () => console.log("PRESSED!");
const ParentComponent = () => {
return <MemoisedComponent onPress={onPress} />;
};
- If a function requires access to any component state, wrap it in
useCallback
with the correct dependencies.
const ParentComponent = ({ id }) => {
const onPress = useCallback(() => {
console.log("PRESSED!", id);
}, [id]);
return <MemoisedComponent onPress={onPress} />;
};
Passing JSX as props to memoised components
Similar to the issues around passing object literals and inline functions, the result of re-creating JSX each render is a different reference which means a re-render will be triggered. This is because <Component /> === <Component />
will evaluate to false
.
JSX produces an instance of React.createElement
, which is an object. By passing JSX into a prop, that object is being recreated each render.
const Component = () => {
return <FlatList {...props} ListHeaderComponent={<ListHeaderComponent />} />;
};
Solution
Pass a React element to optimised components expecting a React element for a prop. A React element is an object that provides a description of the component instance that will be created. That element should be declared in the outer scope, or wrapped in useMemo
, to ensure it is referentially stable.
/* Notice in both examples we aren't wrapping `ListHeaderComponent` in open/close tags */
const ListHeaderComponent = () => <View />;
const EntrantsList = () => {
return <FlatList {...props} ListHeaderComponent={ListHeaderComponent} />;
};
/* or if you need access to component state */
const EntrantsList = () => {
const ListHeaderComponent = useMemo(() => <View />, []);
return <FlatList {...props} ListHeaderComponent={ListHeaderComponent} />;
};
Using React.memo
on components that receive JSX children
JSX tags are used to invoke React.createElement
which returns an instance of a React element. This element instance will have a different identity each time it is created, meaning a component performing equality checks on them will fail.
export const Component1 = React.memo(({ children }) => {
return <View>{children}</View>;
});
const Component2 = () => {
return (
<Component1>
<Text>I am breaking memoisation!</Text>
</Component1>
);
};
In this instance memoising Component1
will not prevent re-renders because children
will have a new identity each render.
This only applies to children that output React elements, not primitive values.
A component that just receives something like a string or number as children, such as a text component, can be memoised normally.
/* THIS IS FINE */
const ParentComponent = () => {
return <ChildComponent>I am a primitive child</ChildComponent>;
};
const ChildComponent = ({ children }) => {
return <Text>{children}</Text>;
};
Solution
Look at achieving optimisations in places other than component memoisation. This could mean memoising any components further down the tree that don't take children
or optimising other props re-created each render. Address any issues that might be causing slow renders or whatever else suggested the component should be memoised.
Delayed state initialisation
A common pattern used for setting component state is through the use of the useEffect
hook.
const Component = (props) => {
const availableExotics = useExotics(props.id);
const [selectedProductId, setSelectedProductId] = useState("");
useEffect(() => {
if (availableExotics[0]) {
setSelectedProductTypeId(availableExotics[0].product_type_id);
}
}, [availableExotics]);
};
In this example the value of selectedProductId
changes when the value of availableExotics
changes, but it could also change when another action calls setSelectedProductId
such as a button press.
The issue is that when the component mounts and the render method is run for the first time the initial value of selectedProductId
will trigger setState
again. React will change the value of the state, and as a result the component will re-render.
Solution
This superfluous re-render could be skipped if the state was initialised with the correct value, which in this instance would be derived from availableExotics
.
const Component = (props) => {
const availableExotics = useExotics(props.id);
const firstExotic = availableExotics[0];
const [selectedProductId, setSelectedProductId] = useState(
firstExotic?.product_type_id ?? ""
);
useEffect(() => {
if (firstExotic) {
setSelectedProductTypeId(firstExotic.product_type_id);
}
}, [firstExotic]);
};
Because the state setter has been invoked with the same value, React won't update the state value and trigger a re-render.
Unnecessarily controlling inputs
Especially when coming from React web, it's (rightfully) natural to implement a text input in this sort of way:
const ComponentWithInput = () => {
const [value, setValue] = useState("");
return <TextInput onChangeText={(text) => setValue(text)} value={value} />;
};
This is called a controlled component
, meaning that the input's value is always driven by React's state. This approach can work in a lot of cases, but due to the inherent limitations of the React Native bridge it can cause jankiness and issues when rapidly inputting text, expecially on lower end devices.
- The native UI has to register a press.
- That event has to be sent over the bridge to JS.
- That event triggers a call to the
onChangeText
function . - That runs the
setState
function. - The state value is updated and the prop is updated.
- The change in the component state is sent back over the bridge to the native side.
- The native UI updates the value in the native component.
- Repeat.
As a result the bridge can become congested, and the user can experience less than optimal text inputs.
Solution
Where possible use a combination of a ref
on the input and a defaultValue
to manage inputs. This might look something like:
const ComponentWithInput = ({ submit }) => {
const inputRef = useRef<TextInput>();
const clearInput = () => {
inputRef?.current?.clear();
}
const submitForm = () => {
const value = inputRef?.current?.value;
submit(value);
}
return (
<TextInput ref={inputRef} defaultValue='' />
)
}
This isn't always going to work as much of the time an input requies validation or masking. This is a known limitation of React Native and the devs are aware. It will hopefully be addressed with the new architecture.
Poorly managed or declared dependencies
When using React hooks it is important to properly consider the values inside each dependency array. We fortunately have a number of eslint
rules setup to catch improper usage, but that rule can't catch everything, and you should still make sure you're verifying each dependency.
When declaring a dependency array be sure to consider the lifecycle of each value inside of it.
const ParentComponent = ({ IDs, addToBetslip }) => {
const raceEntrantIDs = IDs ?? [];
const onSubmit = (value) => {
// some logic
addToBetslip(value);
};
const onPress = useCallback(() => {
if (raceEntrantIDs.length > 5) {
onSubmit(raceEntrantIDs[0]);
}
}, [onSubmit, raceEntrantIDs]);
return <MemoisedComponent prop={raceEntrantIDs} onPress={onPress} />;
};
In the above example there are two main potential issues:
- IF
IDs
evaluates to falsy between rendersMemoisedComponent
will unnecessarily re-render despite receiving the empty array each time. This is because the re-render ofParentComponent
will re-create that empty array. onPress
is being re-created each render despite being wrapped inuseCallback
. This is because it has a dependency ononSubmit
which will have a new reference each render, re-running theuseCallback
hook.
Solution
These could be solved by applying the tactics described in other similar anti-pattern solutions.
const EMPTY_ARRAY = [];
const ParentComponent = ({ IDs, onAddToBetslip }) => {
const raceEntrantIDs = IDs ?? EMPTY_ARRAY;
const onSubmit = useCallback((value) => {
// some logic
onAddToBetslip(value);
}, []);
const onPress = useCallback(() => {
if (raceEntrantIDs.length > 5) {
onSubmit(raceEntrantIDs[0]);
}
}, [onSubmit, raceEntrantIDs]);
return <MemoisedComponent prop={raceEntrantIDs} onPress={onPress} />;
};
Note: If you do find yourself having to implement this "cascading" memoisation, where you are wrapping function after function in useCallback
just to satisfy this rule, it could be an indication that you should look at the way you have written your components. Here is an example:
const Component = () => {
// If addToBetslip is wrapped in useCallback this will need to be a dep and will also have to be wrapped in useCallback
const formatAmount = (amount) => {
if (amount > 100) {
return formatItThisWay(amount);
} else {
return formatItThatWay(amount);
}
};
// This needs to be added to the deps of handleButtonPress and wrapped in useCallback
const addToBetslip = (value) => {
const formattedStake = formatAmount(value);
dispatch(betslipActions.addToBetslip(formattedStake));
};
// This needs to be memoised because it's passed to a memoised component
const handleButtonPress = useCallback((value) => {
// some logic
addToBetslip(value);
}, []);
return <MemoisedComponent onPress={handleButtonPress} />;
};
Using "render" functions inside components
When needing to conditionally render components within JSX it's common to extract that render logic into a separate component scoped to the parent. This might look something like this:
const Component = ({ raceID, resulted }) => {
const renderOtherComponent = () => {
if (!raceID) return <Component1 />
if (resulted) return <Component2 />
return <Component3 />
}
return (
<SomeOutComponent>
{renderOtherComponent()}
</SomeOuterComponent>
)
}
The issue with this is that the renderOtherComponent
function is unnecessarily recalculated each render, and new JSX is produced, even if the props have stayed the same.
Solution
This could be fixed in two ways:
- Wrapping the "render" function in
useMemo
:
const Component = ({ raceID, resulted }) => {
const ConditionalComponent = useMemo(() => {
if (!raceID) return <Component1 />;
if (resulted) return <Component2 />;
return <Component3 />;
}, [raceID, resulted]);
return <SomeOuterComponent>{ConditionalComponent}</SomeOuterComponent>;
};
- Creating a separate memoised component:
const ConditionalComponent = React.memo(({ raceID, resulted }) => {
if (!raceID) return <Component1 />;
if (resulted) return <Component2 />;
return <Component3 />;
});
const Component = () => {
return (
<SomeOuterComponent>
<ConditionalComponent raceID={raceID} resulted={resulted} />
</SomeOuterComponent>
);
};
Failing to memoise a value from the root to the end
When memoising a value to pass down as props it is important to maintain that memoisation until that value is utilised and reaches its "end". Failing to do so may mean components behave as if no memoisation is occurring, and in some scenarios can even cause a performance hit because of the overhead of memoising some values.
const Races = ({ raceIDs }) => {
const { isRaceAvailable } = useMadeUpAvailabilityHook();
const availableRaces = raceIDs.filter((id) => isRaceAvailable(id));
return <RaceCard availableRaces={availableRaces} />;
};
const RaceCard = ({ availableRaces }) => {
const races = useMemo(() => {
return availableRaces.map((race) => mappingLogic(race));
}, [availableRaces]);
return <MemoisedList races={races} />;
};
const MemoisedList = React.memo(({ races }) => {
return <FlatList {...props} data={races} />;
});
In the above example there is three elements. The first defines an array called availableRaces
which is both the root of the value, and root of the issue. That array is being passed to RaceCard
which is executing some mapping logic and correctly memoising the result before passing it to a memoised list component.
This sort of scenario is common and it's easy to overlook the issue, but because availableRaces
inside Races
isn't memoised, is being re-created each render, and isn't a primitive type meaning it has a different identity each render, it is defeating all down chain memoisation. The useMemo
inside RaceCard
will re-run every render even if the values inside of availableRaces
haven't changed, which will cause React.memo
to evaluate the old and new props as changed and causing a re-render of the memoised component.
Solution
Unfortunately there is no "do this" solution to this problem, so it's important to take a holistic look at your chain of components as well as measure, observe and address any re-renders and performance issues. If you do determine that a value or component needs to be memoised, make sure you check that each dependency or prop it receives will be as stable as possible from the point it is declared.