When I first saw a fully functional app prototype appear in three days with Rork, the feeling was genuinely exciting. Prompts turned into components, screens assembled themselves, and everything actually ran. But two weeks later, when I tried to add a new feature, something felt subtly different. Opening those files felt less familiar each time — not broken, just harder to navigate than I expected.
This isn't a criticism of Rork. AI-generated code is optimized for one specific goal: make it work as fast as possible. That is entirely the right design philosophy for getting from zero to working prototype. But if you're building an app you want to grow over months and years — adding features, responding to user feedback, scaling what works — there's a specific transition point where "code that runs" needs to become "code that can be maintained."
Having built apps that have reached over 50 million combined downloads as a solo developer, I've found that whether an app survives long-term often depends less on the initial implementation quality and more on whether that first refactoring pass happened thoughtfully. This guide covers the six patterns that show up most often in Rork-generated React Native code, and how to bring each one up to production standards with concrete Before/After examples throughout.
Why Rork-Generated Code Needs Refactoring
Before diving into fixes, it's worth understanding why this happens in the first place. Rork's generation engine is optimized for correctness and speed: generate code that matches the described functionality as quickly as possible, in a form that the user can immediately test and verify.
That optimization naturally produces certain trade-offs. The code is self-contained (everything needed to make a screen work is in one place), concrete (no abstraction layers that might confuse a first read), and pragmatic (it uses the most common patterns rather than the most architecturally pure ones). These are genuinely useful properties during the rapid prototyping phase.
What they're not optimized for is what happens in month two or three: when a new feature interacts with existing state, when a bug needs to be tracked down across a 300-line component, or when a junior developer joins the project and needs to understand the data flow.
Examining real Rork-generated projects reveals six recurring patterns that create friction as an app grows:
- Type omission with
any: Speed is prioritized over type safety, so places where type inference is complex are stubbed withanyorunknown. - Monolithic screen components: A single file accumulates 200–400 lines mixing UI, business logic, and data fetching.
- Scattered
useStatehooks: Five to ten independentuseStatecalls manage state that is actually interdependent. - Dangerous async patterns inside
useEffect: Directly passingasynccallbacks touseEffectcreates memory leak risk. - No render optimization:
memo,useCallback, anduseMemoare absent, causing child components to re-render on every parent update. - Untestable structure: API calls are embedded directly inside components with no abstraction layer.
Let's work through each one with real code and clear remediation steps.
Step 1 — TypeScript Safety: Eliminating any
Rork-generated code that handles API responses frequently looks like this:
// ❌ Typical Rork-generated pattern
const [userData, setUserData] = useState<any>(null);
const [posts, setPosts] = useState<any[]>([]);
const fetchData = async () => {
const res = await fetch('/api/user');
const data = await res.json(); // data is any — TypeScript won't catch typos
setUserData(data);
setPosts(data.posts); // data.posts is also any
};This runs fine. But a typo like userData.naem passes TypeScript silently, and three months later you'll need to re-read your API documentation just to remember what fields exist on data. The cognitive overhead compounds every time you touch this code.
// ✅ After refactoring
type User = {
id: string;
name: string;
email: string;
avatarUrl: string | null;
};
type Post = {
id: string;
title: string;
body: string;
createdAt: string;
author: Pick<User, 'id' | 'name'>;
};
type UserWithPosts = User & {
posts: Post[];
};
const [userData, setUserData] = useState<UserWithPosts | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
const fetchData = async (): Promise<void> => {
const res = await fetch('/api/user');
if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
const data: UserWithPosts = await res.json();
setUserData(data);
setPosts(data.posts);
};Create a src/types/ directory in your Rork project (it won't exist by default) and export your domain types from separate files like user.ts and post.ts. Sharing them across screens eliminates redundant type declarations and gives your IDE accurate autocomplete everywhere.
A note from real-world usage: Rork almost never types the return value of res.json(). Rather than asserting with as UserWithPosts (which TypeScript accepts without checking), use satisfies UserWithPosts (TypeScript 4.9+). It validates the structural match at compile time without widening the inferred type — a subtly safer choice that catches shape mismatches your API might introduce during development.
Also worth adding at this stage: response validation with a lightweight library like zod. For production apps, trusting that res.json() always matches your types is an assumption that will eventually break.
// Optional but valuable: runtime validation with zod
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
avatarUrl: z.string().nullable(),
});
// This throws a clear error at the boundary if the API shape changes
const user = UserSchema.parse(await res.json());Step 2 — Breaking Apart Bloated Screen Components
Screen components generated by Rork in a single pass can easily reach 250–400 lines. A user profile screen might contain the header, avatar, stats section, post list, and follow button — all in one file, with their logic interleaved.
// ❌ Monolithic screen (abbreviated to show the structure)
export default function ProfileScreen() {
const [user, setUser] = useState<any>(null);
const [posts, setPosts] = useState<any[]>([]);
const [isFollowing, setIsFollowing] = useState(false);
const [followerCount, setFollowerCount] = useState(0);
// fetchUser, fetchPosts, handleFollow, handleUnfollow all inline...
// 30 lines of useEffect calls...
return (
<ScrollView style={styles.container}>
{/* Header: ~50 lines of JSX */}
{/* Stats row: ~30 lines */}
{/* Post list with FlatList: ~80 lines */}
{/* Follow button with animation: ~40 lines */}
</ScrollView>
);
// styles object: another 50-80 lines at the bottom
}The issue isn't just length. It's that a bug in the follow button logic requires you to navigate through hundreds of lines that have nothing to do with following. A change to the stats display forces you to re-read post-fetching code to avoid accidentally breaking something nearby.
When deciding what to extract, three criteria help:
- Is it reusable? Can another screen use this component without modification?
- Does it own distinct logic? Does it have its own data, state, or side effects?
- Do I want to test it independently? Would isolating it make testing simpler?
// ✅ After splitting into focused components
// src/components/profile/ProfileHeader.tsx
type ProfileHeaderProps = {
user: User;
isFollowing: boolean;
onFollowToggle: () => void;
};
export function ProfileHeader({ user, isFollowing, onFollowToggle }: ProfileHeaderProps) {
return (
<View style={styles.header}>
<Image
source={{ uri: user.avatarUrl ?? undefined }}
style={styles.avatar}
defaultSource={require('../../../assets/default-avatar.png')}
/>
<Text style={styles.name}>{user.name}</Text>
<FollowButton isFollowing={isFollowing} onPress={onFollowToggle} />
</View>
);
}
// src/components/profile/ProfileStats.tsx
type ProfileStatsProps = {
postCount: number;
followerCount: number;
followingCount: number;
};
export function ProfileStats({ postCount, followerCount, followingCount }: ProfileStatsProps) {
return (
<View style={styles.statsRow}>
<StatItem label="Posts" value={postCount} />
<StatItem label="Followers" value={followerCount} />
<StatItem label="Following" value={followingCount} />
</View>
);
}
// src/hooks/useProfile.ts — all logic lives here, not in the screen
export function useProfile(userId: string) {
const [state, dispatch] = useReducer(profileReducer, { status: 'idle' });
const loadProfile = useCallback(async () => {
dispatch({ type: 'FETCH_START' });
try {
const [user, posts] = await Promise.all([
getUser(userId),
getUserPosts(userId),
]);
dispatch({ type: 'FETCH_SUCCESS', user, posts });
} catch (err) {
dispatch({ type: 'FETCH_ERROR', message: String(err) });
}
}, [userId]);
useEffect(() => { loadProfile(); }, [loadProfile]);
const handleFollowToggle = useCallback(async () => {
if (state.status !== 'success') return;
await toggleFollow(userId, state.isFollowing);
dispatch({ type: 'TOGGLE_FOLLOW' });
}, [state, userId]);
return { state, handleFollowToggle };
}
// src/screens/ProfileScreen.tsx — now a pure layout coordinator
export default function ProfileScreen({ route }: ProfileScreenProps) {
const { userId } = route.params;
const { state, handleFollowToggle } = useProfile(userId);
if (state.status === 'loading') return <LoadingScreen />;
if (state.status === 'error') return <ErrorScreen message={state.message} />;
if (state.status !== 'success') return null;
return (
<ScrollView style={styles.container}>
<ProfileHeader
user={state.user}
isFollowing={state.isFollowing}
onFollowToggle={handleFollowToggle}
/>
<ProfileStats
postCount={state.posts.length}
followerCount={state.followerCount}
followingCount={state.followingCount}
/>
<PostList posts={state.posts} />
</ScrollView>
);
}The screen component now reads like a table of contents for the screen's structure. Every piece of logic lives in useProfile, which is independently readable and testable. Changes to the follow button don't require touching the stats component, and vice versa.
Step 3 — Untangling useState Sprawl
Rork-generated screens often contain clusters of useState calls that appear independent but are deeply coupled:
// ❌ Sprawling useState calls with implicit relationships
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<Post[] | null>(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);These values carry unstated invariants. When isLoading is true, error should always be null. When error is set, isLoading must be false. When data is null, page should be 1. Managing these rules across five separate setState calls means any forgotten update creates an inconsistent state that can produce confusing UI bugs — a loading spinner alongside an error message, or stale data persisting after a new fetch begins.
// ✅ Discriminated union makes impossible states unrepresentable
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T; page: number; hasMore: boolean }
| { status: 'error'; message: string };
type FetchAction<T> =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; data: T; hasMore: boolean }
| { type: 'FETCH_ERROR'; message: string }
| { type: 'LOAD_MORE' }
| { type: 'RESET' };
function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
switch (action.type) {
case 'FETCH_START':
return { status: 'loading' };
case 'FETCH_SUCCESS':
return {
status: 'success',
data: action.data,
page: 1,
hasMore: action.hasMore,
};
case 'FETCH_ERROR':
return { status: 'error', message: action.message };
case 'LOAD_MORE':
if (state.status !== 'success') return state;
return { ...state, page: state.page + 1 };
case 'RESET':
return { status: 'idle' };
}
}With this pattern, TypeScript enforces the invariants at the type level. state.data is only accessible inside status === 'success' branches. You can't accidentally read state.page while status === 'loading' because the type doesn't have that field in that branch. The compiler becomes your runtime guard.
For a broader look at state management options including Zustand and Jotai, see Advanced State Management Patterns for Rork Apps. A practical guideline for when to migrate: if you have fewer than four useState calls with no shared invariants, keep useState. When mutual dependencies appear, or when you're managing more than four related values, useReducer with discriminated unions starts paying its weight.
Step 4 — Async Patterns and Memory Leak Prevention
One of Rork's most consistently generated patterns is passing an async function directly to useEffect:
// ❌ Async directly in useEffect — causes memory leaks and React warnings
useEffect(async () => {
const data = await fetchPosts();
setPosts(data); // Called after component unmounts if navigation occurs during fetch
}, [userId]);useEffect expects its callback to return either nothing or a cleanup function. An async function always returns a Promise — which React silently discards. More critically, if the component unmounts before fetchPosts() resolves (because the user navigated away, for example), setPosts will be called on an unmounted component. React 18 no longer shows the "Can't perform state update on unmounted component" warning, but the behavior can still produce memory leaks and unexpected state transitions when the component remounts.
// ✅ Cancellable async pattern with proper cleanup
useEffect(() => {
let cancelled = false; // Mount guard
const loadPosts = async () => {
try {
dispatch({ type: 'FETCH_START' });
const data = await fetchPosts(userId);
if (!cancelled) {
dispatch({
type: 'FETCH_SUCCESS',
data,
hasMore: data.length === PAGE_SIZE,
});
}
} catch (err) {
if (!cancelled) {
dispatch({
type: 'FETCH_ERROR',
message: err instanceof Error ? err.message : 'Failed to load posts',
});
}
}
};
loadPosts();
return () => {
cancelled = true; // Prevents state updates after unmount
};
}, [userId]);For network requests specifically, AbortController provides cancellation at the HTTP level — the request is terminated rather than just ignored after completion:
useEffect(() => {
const controller = new AbortController();
const loadPosts = async () => {
try {
dispatch({ type: 'FETCH_START' });
const res = await fetch(`/api/posts?userId=${userId}`, {
signal: controller.signal, // Attach the abort signal
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: Post[] = await res.json();
dispatch({ type: 'FETCH_SUCCESS', data, hasMore: data.length === PAGE_SIZE });
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return; // Intentional cancellation
dispatch({ type: 'FETCH_ERROR', message: String(err) });
}
};
loadPosts();
return () => controller.abort(); // Cancel in-flight request on unmount
}, [userId]);The AbortController approach is worth adopting in apps with frequent navigation, where users move between screens before data loads complete. The cancelled flag approach is simpler and covers most cases in apps with fewer concurrent fetches.
Before shipping any Rork-generated project, audit every useEffect for inline async usage. A global search for useEffect(async will find them all. It takes roughly ten minutes to fix and eliminates a category of production errors entirely.
Step 5 — Stopping Unnecessary Re-renders
Performance optimization has a non-negotiable rule: measure first, then optimize. React DevTools Profiler is the right tool — open it, record an interaction that feels slow, and check which components are highlighted as re-rendering. Most of the time, you'll find a small set of components doing most of the unnecessary work.
That said, Rork-generated list components have a predictable pattern that causes unnecessary re-renders in scroll-heavy screens, and it's worth knowing in advance:
// ❌ New function reference on every render defeats React.memo
function PostList({ posts, onLike }: { posts: Post[]; onLike: (id: string) => void }) {
return (
<FlatList
data={posts}
renderItem={({ item }) => (
// A new arrow function and a new PostItem props object every render:
<PostItem post={item} onLike={onLike} />
)}
keyExtractor={(item) => item.id}
/>
);
}Even if PostItem is wrapped in React.memo, the inline arrow function in renderItem creates a new function reference on every render of PostList. From PostItem's perspective, a prop has changed (the function reference), so it re-renders anyway — making React.memo ineffective.
// ✅ Stable references with memo + useCallback
// Wrap PostItem in React.memo so it only re-renders when its own props change
const PostItem = React.memo(function PostItem({
post,
onLike,
}: {
post: Post;
onLike: (id: string) => void;
}) {
return (
<View style={styles.item}>
<Image source={{ uri: post.coverImage }} style={styles.cover} />
<View style={styles.content}>
<Text style={styles.title}>{post.title}</Text>
<Text style={styles.body} numberOfLines={2}>{post.body}</Text>
</View>
<TouchableOpacity onPress={() => onLike(post.id)} style={styles.likeButton}>
<Text style={styles.likeIcon}>❤️</Text>
</TouchableOpacity>
</View>
);
});
function PostList({ posts, onLike }: { posts: Post[]; onLike: (id: string) => void }) {
// useCallback ensures handleLike is the same reference across renders
// (unless onLike itself changes)
const handleLike = useCallback((id: string) => {
onLike(id);
}, [onLike]);
// Move renderItem outside of JSX (or use useCallback for it)
const renderItem = useCallback(({ item }: { item: Post }) => (
<PostItem post={item} onLike={handleLike} />
), [handleLike]);
return (
<FlatList
data={posts}
renderItem={renderItem}
keyExtractor={(item) => item.id}
removeClippedSubviews={true} // Unmount off-screen items
maxToRenderPerBatch={10} // Limit items rendered per frame
windowSize={5} // Keep 5 screens worth of items in memory
initialNumToRender={10} // Render 10 items on first pass
/>
);
}The FlatList props at the bottom (removeClippedSubviews, maxToRenderPerBatch, windowSize) are Rork-generated defaults that are often left at their defaults. On lists with complex items (images, nested views), tuning these values makes a noticeable difference on lower-end Android devices.
When not to use memo: Simple presentational components that render quickly don't benefit from memoization — the comparison cost can outweigh the saved render. Apply it where you've measured actual unnecessary re-rendering, or on components with expensive rendering (charts, complex animations, heavy image layouts).
Step 6 — Making Code Testable
The reason Rork-generated code is difficult to unit test comes down to one structural issue: API calls are embedded directly inside components, with no injectable seam for mocking.
// ❌ Direct fetch inside component — requires module-level patching to test
export default function UserListScreen() {
const [users, setUsers] = useState<any[]>([]);
useEffect(() => {
fetch('https://api.example.com/users') // Can't inject a mock here
.then(res => res.json())
.then(data => setUsers(data));
}, []);
return (
<FlatList
data={users}
renderItem={({ item }) => <Text>{item.name}</Text>}
keyExtractor={(item) => item.id}
/>
);
}The fix is a three-layer architecture that separates concerns cleanly:
// Layer 1: src/api/users.ts — the network boundary
export async function getUsers(): Promise<User[]> {
const res = await fetch('https://api.example.com/users');
if (!res.ok) throw new Error(`Failed to fetch users: ${res.status}`);
return res.json();
}
export async function getUserById(id: string): Promise<User> {
const res = await fetch(`https://api.example.com/users/${id}`);
if (!res.ok) throw new Error(`Failed to fetch user ${id}: ${res.status}`);
return res.json();
}// Layer 2: src/hooks/useUsers.ts — logic and state management
import { getUsers } from '../api/users';
export function useUsers() {
const [state, dispatch] = useReducer(
fetchReducer<User[]>,
{ status: 'idle' }
);
const load = useCallback(async () => {
let cancelled = false;
dispatch({ type: 'FETCH_START' });
try {
const data = await getUsers(); // Mockable at the module level
if (!cancelled) {
dispatch({ type: 'FETCH_SUCCESS', data, hasMore: false });
}
} catch (err) {
if (!cancelled) {
dispatch({
type: 'FETCH_ERROR',
message: err instanceof Error ? err.message : 'Unknown error',
});
}
}
return () => { cancelled = true; };
}, []);
useEffect(() => {
const cleanup = load();
return () => { cleanup.then(fn => fn?.()); };
}, [load]);
return { state, reload: load };
}// Layer 3: src/screens/UserListScreen.tsx — pure presentation
export default function UserListScreen() {
const { state, reload } = useUsers();
if (state.status === 'loading') {
return <ActivityIndicator size="large" style={styles.loader} />;
}
if (state.status === 'error') {
return (
<View style={styles.error}>
<Text style={styles.errorText}>{state.message}</Text>
<TouchableOpacity onPress={reload} style={styles.retryButton}>
<Text style={styles.retryText}>Retry</Text>
</TouchableOpacity>
</View>
);
}
if (state.status !== 'success') return null;
return (
<FlatList
data={state.data}
renderItem={({ item }) => <UserCard user={item} />}
keyExtractor={(item) => item.id}
/>
);
}// __tests__/useUsers.test.ts — clean, isolated tests
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { useUsers } from '../src/hooks/useUsers';
import * as usersApi from '../src/api/users';
jest.mock('../src/api/users');
const mockUsers: User[] = [
{ id: '1', name: 'Alice', email: 'alice@example.com', avatarUrl: null },
{ id: '2', name: 'Bob', email: 'bob@example.com', avatarUrl: 'https://example.com/bob.png' },
];
describe('useUsers', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('starts in idle state', () => {
(usersApi.getUsers as jest.Mock).mockResolvedValue([]);
const { result } = renderHook(() => useUsers());
// Immediately after mount but before useEffect fires
// status will be 'loading' since load() is called in useEffect
});
it('transitions to success state with fetched users', async () => {
(usersApi.getUsers as jest.Mock).mockResolvedValue(mockUsers);
const { result } = renderHook(() => useUsers());
await waitFor(() => {
expect(result.current.state.status).toBe('success');
});
if (result.current.state.status === 'success') {
expect(result.current.state.data).toEqual(mockUsers);
expect(result.current.state.data).toHaveLength(2);
}
});
it('transitions to error state when fetch fails', async () => {
(usersApi.getUsers as jest.Mock).mockRejectedValue(new Error('Network error'));
const { result } = renderHook(() => useUsers());
await waitFor(() => {
expect(result.current.state.status).toBe('error');
});
if (result.current.state.status === 'error') {
expect(result.current.state.message).toBe('Network error');
}
});
it('reloads data when reload is called', async () => {
(usersApi.getUsers as jest.Mock).mockResolvedValue(mockUsers);
const { result } = renderHook(() => useUsers());
await waitFor(() => expect(result.current.state.status).toBe('success'));
(usersApi.getUsers as jest.Mock).mockResolvedValue([...mockUsers, {
id: '3', name: 'Charlie', email: 'charlie@example.com', avatarUrl: null,
}]);
await act(() => result.current.reload());
await waitFor(() => {
if (result.current.state.status === 'success') {
expect(result.current.state.data).toHaveLength(3);
}
});
});
});You don't need full test coverage to benefit from this structure. Start with custom hooks that contain real business logic — the places where a silent regression would produce incorrect behavior users notice. A few well-written hook tests give you a safety net for the most common refactoring scenarios. For a deeper dive into Jest setup for React Native, see Unit Testing with Jest for Rork Apps.
Step 7 — Organizing Your Project for Long-Term Maintenance
One structural issue Rork-generated projects share is the absence of a deliberate folder organization. Files accumulate in the default structure, and as screens multiply, finding the right component or hook requires browsing rather than knowing.
A folder structure that scales well for Rork-based projects:
src/
├── api/ # Network layer — one file per domain entity
│ ├── users.ts
│ ├── posts.ts
│ └── index.ts # Re-export everything for clean imports
├── components/ # Shared UI components
│ ├── common/ # Button, Input, LoadingScreen, ErrorView
│ └── profile/ # Screen-specific components (ProfileHeader, ProfileStats)
├── hooks/ # Custom hooks — co-located with their data domain
│ ├── useUsers.ts
│ ├── usePosts.ts
│ └── useProfile.ts
├── screens/ # One file per screen
│ ├── ProfileScreen.tsx
│ └── UserListScreen.tsx
├── types/ # Shared TypeScript types
│ ├── user.ts
│ ├── post.ts
│ └── index.ts
├── utils/ # Pure utility functions
│ └── date.ts
└── constants/ # App-wide constants
└── api.ts
This isn't about following a specific architecture pattern like MVC or MVVM. It's about having a convention your future self will immediately recognize — one where you know that "anything touching the network lives in api/," "anything with state lives in hooks/," and "anything rendering UI lives in components/ or screens/."
Prioritizing the Work: Where to Start
Attempting all seven steps at once is one of the most reliable ways to stall a project. For solo developers, the tension between "do it properly" and "ship something" is real, and prioritization matters more than completeness.
The framework that works for me is business risk × change frequency:
Fix before shipping (non-negotiable):
- Memory leaks in
useEffectasync patterns (Step 4): These surface as production crashes that users report and that affect App Store ratings. - Missing error handling for network requests: Failures without user feedback appear to users as freezes or blank screens.
- TypeScript
anyin form validation or payment flows: Silent type errors in critical user flows cause data integrity problems.
Address in the next 1–2 weeks (before adding the next major feature):
- TypeScript types throughout (Step 1): New features land bugs in untyped areas consistently. The investment pays back with the second feature you add.
- Splitting screens you expect to modify (Step 2): Prioritize by which screens appear in your feature roadmap.
useReducerfor screens with more than four related state variables (Step 3): Especially important before adding pagination or multi-step flows.
Do when you have time (steady quality improvements):
- Re-render optimization (Step 5): Measure first. Optimize the specific components the profiler identifies.
- Tests (Step 6): Add incrementally, starting with custom hooks that contain your most critical business logic.
- Folder reorganization (Step 7): Do this once during a quiet period, not in the middle of a feature sprint.
The goal isn't a perfect codebase before your first release. It's growing confidence that the areas you've refactored won't break unexpectedly — and that each new feature you add feels slightly more natural than the last one.
Next Step
Open your current Rork project and search for useEffect(async. If you find matches, that's your first fix — apply the cancellable pattern from Step 4. It takes under ten minutes and eliminates a category of production errors entirely.
From there, create a src/types/ directory and start defining types for your most-used API responses. The IDE feedback improvement is immediate, and the time investment in the first week pays back across every feature you add afterward.