Sitemap

My React Native Journey: From Overusing Redux to Smarter State Management

7 min readMar 18, 2025
Press enter or click to view image in full size
Photo by Oskar Yildiz on Unsplash

It started in 2019, when I got project that need me using React Native (The reason why they told us using RN because they need to build app faster for Android and iOS but because I am still new in RN I think that not making any difference 😂).

My lead told me to use Redux to build the app, but as you know I am still beginner in RN and we are given 3 month and total 7/8 monts to finish and maintenance the app so without deep understanding we just replace all useState using Redux, and that is the nightmare for us wk wk …

Project still deliver on time but code quality is questionable. So what is Redux and why we need Redux ?

Redux

Redux is a JS library for predictable and maintainable global state management. [Redux]

So when we need Redux or State Management ?

  • Global state management (e.g., user authentication, theme, UI state).
  • Client-side state persistence (e.g., store form inputs across pages).
  • Sharing state between multiple components without prop drilling.

That mean we dont need Redux or any State Management in each page we create.

For separating logic from UI we can use Custom Hooks.

import {
ActivityIndicator,
FlatList,
View,
Image,
Dimensions,
ListRenderItem,
} from "react-native";
import { Card, Text, Button } from "react-native-paper";
import { useCallback, useEffect, useMemo, useState } from "react";
import Character from "@/types/character";

const numColumns = 2; // Number of columns in grid
const screenWidth = Dimensions.get("window").width;
const cardWidth = screenWidth / numColumns - 16; // Adjust spacing

export default function Index() {
const [data, setData] = useState<Character[]>([]);
const [error, setError] = useState<any>(undefined);
const [isLoading, setIsLoading] = useState(false);
const [isFetchingMore, setIsFetchingMore] = useState(false);
const [page, setPage] = useState(1);
const [hasNextPage, setHasNextPage] = useState(true);

useEffect(() => {
fetchData(page);
}, []);

const fetchData = async (pageNumber: number) => {
try {
if (pageNumber === 1) setIsLoading(true);
else setIsFetchingMore(true);

const response = await fetch(
`https://rickandmortyapi.com/api/character?page=${pageNumber}`
);
const json = await response.json();

if (pageNumber === 1) {
setData(json.results);
} else {
setData((prevData) => [...prevData, ...json.results]);
}

setHasNextPage(json.info?.next !== null);
} catch (error) {
setError(error);
} finally {
setIsLoading(false);
setIsFetchingMore(false);
}
};

const loadMore = () => {
if (hasNextPage && !isFetchingMore) {
setPage((prevPage) => {
const nextPage = prevPage + 1;
fetchData(nextPage);
return nextPage;
});
}
};

const keyExtractor = useMemo(
() => (item: any, index: number) => String(index),
[]
);

const renderItem: ListRenderItem<any> = useCallback(({ item }) => {
return (
<View style={{ width: cardWidth }} className="my-1">
<Card className="overflow-hidden">
<Image source={{ uri: item.image }} className="h-44" />
<Card.Content>
<Text variant="titleMedium">{item.name}</Text>
<Text>{item.species}</Text>
<Text>{item.location.name}</Text>
</Card.Content>
</Card>
</View>
);
}, []);

if (isLoading) {
return (
<View className="flex-1 justify-center items-center">
<ActivityIndicator size="large" />
<Text>Loading characters...</Text>
</View>
);
}

if (error) {
return (
<View className="flex-1 justify-center items-center">
<Text className="text-center text-red-500">Failed to load data</Text>
<Button
mode="contained"
onPress={() => {
fetchData(1);
}}
>
Retry
</Button>
</View>
);
}

return (
<View className="flex-1">
<FlatList
data={data}
keyExtractor={keyExtractor}
numColumns={numColumns} // Enable grid layout
columnWrapperStyle={{
justifyContent: "space-between",
paddingHorizontal: 8,
}} // Evenly distribute columns
renderItem={renderItem}
initialNumToRender={10} // Optimize initial render
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={
isFetchingMore ? (
<View className="py-4 items-center">
<ActivityIndicator size="small" />
</View>
) : null
}
/>
</View>
);
}

As you can see, we have Logic in UI. Actually this is not wrong but code is not clean and testable so we break the logic to different file using Custom Hooks.

import {
ActivityIndicator,
FlatList,
View,
Image,
Dimensions,
ListRenderItem,
} from "react-native";
import { Card, Text, Button } from "react-native-paper";
import useIndex from "./index.hooks";
import { useCallback, useMemo } from "react";

const numColumns = 2; // Number of columns in grid
const screenWidth = Dimensions.get("window").width;
const cardWidth = screenWidth / numColumns - 16; // Adjust spacing

export default function Index() {
const { data, error, isLoading, isFetchingMore, refetch, loadMore } =
useIndex();

const keyExtractor = useMemo(
() => (item: any, index: number) => String(index),
[]
);

const renderItem: ListRenderItem<any> = useCallback(({ item }) => {
return (
<View style={{ width: cardWidth }} className="my-1">
<Card className="overflow-hidden">
<Image source={{ uri: item.image }} className="h-44" />
<Card.Content>
<Text variant="titleMedium">{item.name}</Text>
<Text>{item.species}</Text>
<Text>{item.location.name}</Text>
</Card.Content>
</Card>
</View>
);
}, []);

if (isLoading) {
return (
<View className="flex-1 justify-center items-center">
<ActivityIndicator size="large" />
<Text>Loading characters...</Text>
</View>
);
}

if (error) {
return (
<View className="flex-1 justify-center items-center">
<Text className="text-center text-red-500">Failed to load data</Text>
<Button mode="contained" onPress={refetch}>
Retry
</Button>
</View>
);
}

return (
<View className="flex-1">
<FlatList
data={data}
keyExtractor={keyExtractor}
numColumns={numColumns} // Enable grid layout
columnWrapperStyle={{
justifyContent: "space-between",
paddingHorizontal: 8,
}} // Evenly distribute columns
renderItem={renderItem}
initialNumToRender={10} // Optimize initial render
onEndReached={loadMore}
onEndReachedThreshold={0.5}
ListFooterComponent={
isFetchingMore ? (
<View className="py-4 items-center">
<ActivityIndicator size="small" />
</View>
) : null
}
/>
</View>
);
}
import Character from "@/types/character";
import { useEffect, useState } from "react";

export default function useIndex() {
const [data, setData] = useState<Character[]>([]);
const [error, setError] = useState<any>(undefined);
const [isLoading, setIsLoading] = useState(false);
const [isFetchingMore, setIsFetchingMore] = useState(false);
const [page, setPage] = useState(1);
const [hasNextPage, setHasNextPage] = useState(true);

useEffect(() => {
fetchData(page);
}, []);

const fetchData = async (pageNumber: number) => {
try {
if (pageNumber === 1) setIsLoading(true);
else setIsFetchingMore(true);

const response = await fetch(
`https://rickandmortyapi.com/api/character?page=${pageNumber}`
);
const json = await response.json();

if (pageNumber === 1) {
setData(json.results);
} else {
setData((prevData) => [...prevData, ...json.results]);
}

setHasNextPage(json.info?.next !== null);
} catch (error) {
setError(error);
} finally {
setIsLoading(false);
setIsFetchingMore(false);
}
};

const loadMore = () => {
if (hasNextPage && !isFetchingMore) {
setPage((prevPage) => {
const nextPage = prevPage + 1;
fetchData(nextPage);
return nextPage;
});
}
};

return {
data,
error,
isLoading,
isFetchingMore,
refetch: () => fetchData(1),
loadMore,
};
}

But we still can simplify logic using TanStack/Query. [Query]

For UI still the same but Custom Hooks become less code like bellow.

import Character, { APIResponse } from "@/types/character";
import { useInfiniteQuery } from "@tanstack/react-query";

const fetchCharacters = async ({ pageParam = 1 }): Promise<APIResponse> => {
const response = await fetch(
`https://rickandmortyapi.com/api/character?page=${pageParam}`
);

if (!response.ok) {
throw new Error("Failed to fetch data");
}
return response.json();
};

export default function useIndex() {
const {
data,
error,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
refetch,
} = useInfiniteQuery({
queryKey: ["characters"],
queryFn: fetchCharacters,
initialPageParam: 1,
getNextPageParam: (lastPage) => {
return lastPage.info?.next ? lastPage.info.page + 1 : undefined;
},
retry: false, // ✅ Disable automatic retries
});

return {
data: data?.pages.flatMap((page) => page.results) || [],
error,
isLoading,
isFetchingMore: isFetchingNextPage,
refetch,
loadMore: () => hasNextPage && fetchNextPage(),
};
}

After separating the Logic we can UnitTest our Custom Hooks. We use MSW for Mocking API. [MSW]

import { renderHook, act, waitFor } from "@testing-library/react-native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { server } from "@/__mocks__/server";
// import useIndex from "@/app/index.query.hooks";
import useIndex from "@/app/index.axios.query.hooks";
import { http, HttpResponse } from "msw";

describe("useIndex Hook", () => {
const createWrapper = () => {
const queryClient = new QueryClient();
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

it("fetches initial character list successfully", async () => {
const { result } = renderHook(() => useIndex(), {
wrapper: createWrapper(),
});

expect(result.current.isLoading).toBe(true);

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toHaveLength(1);
expect(result.current.data[0].name).toBe("Rick Sanchez");
});
});

it("fetches more data on pagination", async () => {
const { result } = renderHook(() => useIndex(), {
wrapper: createWrapper(),
});

await waitFor(() => expect(result.current.isLoading).toBe(false));

act(() => {
result.current.loadMore();
});

await waitFor(() => {
expect(result.current.data).toHaveLength(2); // Ensure pagination works
});
});

it("handles API errors", async () => {
server.use(
http.get("https://rickandmortyapi.com/api/character", ({ params }) => {
return HttpResponse.error();
})
);

const { result } = renderHook(() => useIndex(), {
wrapper: createWrapper(),
});

await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.error).not.toBeNull();
});
});
});
import { http, HttpResponse } from "msw";

export const handlers = [
http.get("https://rickandmortyapi.com/api/character", ({ request }) => {
// Extract URL parameters
const url = new URL(request.url);
const page = url.searchParams.get("page") || "1"; // Default to page "1"

return HttpResponse.json({
info: {
next: page === "1" ? "2" : null, // Simulate pagination
page: Number(page),
},
results: [
{
id: 1,
name: "Rick Sanchez",
status: "Alive",
species: "Human",
type: "",
gender: "Male",
origin: { name: "Earth", url: "" },
location: { name: "Citadel of Ricks", url: "" },
image: "https://rickandmortyapi.com/api/character/avatar/1.jpeg",
},
],
});
}),
];
import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);

// Start server before all tests
beforeAll(() => server.listen());
// Reset handlers after each test
afterEach(() => server.resetHandlers());
// Close server after all tests
afterAll(() => server.close());

It’s different with Swift when we do UnitTest, in Swift we can use Protocol to Mock not intercepting like this but it is worth to try.

Next

After we learn how to separate Logic from UI using Custom Hooks, next we will learn how to use State Management but I am not using Redux but Zustand.

Source

--

--