React Native Feedback Board Tutorial: React Native SDK 1.0
This tutorial covers React Native SDK 1.0. If you are starting today, use the current React Native tutorial, which covers the latest release. What 1.0 contains is summed up in Introducing the FeedbackKit React Native SDK.
A note on 1.0's responses. The FeedbackKit API names fields in snake case, and the JavaScript SDK that 1.0 is built on passes them through unchanged. So on each request, voteCount, hasVoted, commentCount and createdAt are undefined, while the id, title, description, status and category are fine. The ready-made VoteButton therefore shows no count and cannot remove a vote. This tutorial works around it with a small helper and its own vote control, in sections 4 and 6.
By the end of this guide your React Native app has a screen where people read the requests others have filed, vote for the ones they want, comment, and file their own, on iOS and Android. The examples use Trailmate, a fictional hiking app whose users keep asking for offline maps.
The short version
- Install
feedbackkit-react-nativewithfeedbackkit-jsand AsyncStorage. - Wrap your navigation in
FeedbackProviderwith your project's API key. - Give the provider a user id. The SDK does not create one.
- Build the board, the submit form and the detail screen from the hooks, with the badges for decoration.
- Report each user's MRR through the client, so votes carry weight.
What you need
The package needs React 18 or later, React Native 0.70 or later and @react-native-async-storage/async-storage 1.17 or later. The navigation examples use React Navigation's native stack, but any navigator works. You also need a FeedbackKit project and its API key. Create the project in the FeedbackKit admin app for iPhone, iPad or Mac, and copy the key from the project's settings. Keys start with sf_.
1. Install the package
npm install feedbackkit-react-native feedbackkit-js @react-native-async-storage/async-storage
npm resolves 1.0.1, which is the 1.0.0 code republished. All three packages are peer dependencies, so install them together. On bare React Native, run pod install in the ios folder for AsyncStorage. In an Expo project, add AsyncStorage with npx expo install @react-native-async-storage/async-storage. The FeedbackKit packages are TypeScript and JavaScript with nothing native to link.
If your project type-checks strictly, note that 1.0's package.json has no types entry and its build writes no declaration files, so TypeScript reports that it cannot find one for the module. The package ships its TypeScript source, and a paths entry points the compiler at it:
{
"compilerOptions": {
"paths": {
"feedbackkit-react-native": ["./node_modules/feedbackkit-react-native/src/index.ts"]
}
}
}
2. Add the provider
Wrap the provider around your navigation container, so every screen can use the hooks:
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { FeedbackProvider } from 'feedbackkit-react-native';
import { FEEDBACK_API_KEY } from './feedback/config';
import { RootStack } from './navigation';
import { EnsureFeedbackUser } from './feedback/EnsureFeedbackUser';
export default function App() {
return (
<FeedbackProvider apiKey={FEEDBACK_API_KEY}>
<EnsureFeedbackUser />
<NavigationContainer>
<RootStack />
</NavigationContainer>
</FeedbackProvider>
);
}
apiKey and children are required. userId, theme and baseUrl are optional, and the default baseUrl is FeedbackKit's production server, where projects made in the admin app live. Leave it unset unless you run a FeedbackKit server yourself. The package README shows a FeedbackKitProvider with a projectId prop. Neither exists in the code: the component is FeedbackProvider, and the key identifies the project.
Where the key goes
The key travels as an X-API-Key header. It is a client key for one project, made to ship inside an app, and anyone who unpacks your bundle can read it. It covers end-user actions such as reading, filing, voting and commenting. Keep it out of component code all the same: export it from one module that reads your build configuration, such as Expo's public environment variables or react-native-config, so a rotation touches one file. Regenerating the key in the admin app stops every shipped build that still uses the old one, so plan a rotation around a release.
3. Give the provider a user id
Every request, vote and comment belongs to a user id, and 1.0 does not create one. Without an id, the board still loads, while vote, submit and addComment throw "User ID is required".
If your app has accounts, pass your account id as the userId prop, so one person is one voter on every device. The provider sends the prop on as it changes. Two details: the prop is not saved to AsyncStorage, and setting it back to undefined leaves the previous id in place. On sign-out, call setUserId(undefined) from useFeedbackKitContext(), which clears the id and its stored copy.
If it has no accounts, create an id once and hand it to setUserId, which stores it in AsyncStorage under @feedbackkit/userId. At the next launch the provider reads it back, and the hooks wait for that read before loading anything. This is the EnsureFeedbackUser component from the provider example:
import { useEffect } from 'react';
import { useFeedbackKitContext } from 'feedbackkit-react-native';
function newFeedbackUserId(): string {
const random = Math.random().toString(36).slice(2, 10);
return `trailmate-${Date.now().toString(36)}-${random}`;
}
export function EnsureFeedbackUser() {
const { userId, setUserId, isInitialized } = useFeedbackKitContext();
useEffect(() => {
if (isInitialized && !userId) {
setUserId(newFeedbackUserId());
}
}, [isInitialized, userId, setUserId]);
return null;
}
AsyncStorage lives with the app's data, so deleting the app normally starts a new voter.
4. Read the counts with a helper
As the note at the top explains, a request's counts arrive as vote_count, has_voted and comment_count, and a comment's team flag as is_admin. This helper reads the camel-case name and falls back to the snake-case one, so the code below keeps working if a later release renames the fields:
// feedback/field.ts
type Raw = Record<string, unknown>;
export function field<T>(item: object, camel: string, snake: string, fallback: T): T {
const raw = item as Raw;
return (raw[camel] ?? raw[snake] ?? fallback) as T;
}
5. The board
The quickest board is the ready-made list. It loads the requests, shows a card for each with pull to refresh, and calls you back on a tap:
<FeedbackList
filterByStatus={FeedbackStatus.Approved}
showAddButton
onAddPress={() => navigation.navigate('SuggestIdea')}
onFeedbackPress={(feedback) => navigation.navigate('Idea', { id: feedback.id })}
/>
In 1.0 its cards always include the built-in VoteButton, with the count missing as described above. Votes cast through it do count on the server, but the button cannot show them or take them back. Trailmate builds its own list from the same hook instead, with a vote control that reads the counts:
import React, { useEffect } from 'react';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import {
FeedbackStatus,
StatusBadge,
useFeedbackList
} from 'feedbackkit-react-native';
import type { FeedbackStackParams } from './navigation';
import { VoteControl } from './VoteControl';
type Props = NativeStackScreenProps<FeedbackStackParams, 'Ideas'>;
export function IdeasScreen({ navigation }: Props) {
const { feedbacks, isLoading, error, refetch } = useFeedbackList({
status: FeedbackStatus.Approved
});
useEffect(() => navigation.addListener('focus', () => { void refetch(); }), [navigation, refetch]);
return (
<FlatList
data={feedbacks}
keyExtractor={(item) => item.id}
refreshing={isLoading}
onRefresh={refetch}
ListEmptyComponent={
error ? <Text onPress={refetch}>{error.message}</Text> : null
}
renderItem={({ item }) => (
<Pressable
style={styles.row}
onPress={() => navigation.navigate('Idea', { id: item.id })}
>
<View style={styles.text}>
<StatusBadge status={item.status} size="small" />
<Text style={styles.title}>{item.title}</Text>
<Text numberOfLines={2}>{item.description}</Text>
</View>
<VoteControl feedback={item} />
</Pressable>
)}
/>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', gap: 12, padding: 16 },
text: { flex: 1, gap: 4 },
title: { fontSize: 16, fontWeight: '600' }
});
status: FeedbackStatus.Approved is the decision worth thinking about. Leave it out and users see everything, including requests nobody on your team has read yet. With it, nothing reaches the board until you approve it. The focus listener reloads the list when the user comes back from the form or a detail screen.
6. Voting
The vote control seeds its state from the request, sends the vote or the unvote, and draws the server's answer. Like the built-in button, it waits for that answer rather than guessing:
import React, { useState } from 'react';
import { ActivityIndicator, Pressable, Text } from 'react-native';
import {
FeedbackStatus,
useVote,
type Feedback,
type FeedbackKitError
} from 'feedbackkit-react-native';
import { field } from './field';
export function VoteControl({ feedback }: { feedback: Feedback }) {
const { vote, unvote, isVoting } = useVote();
const [hasVoted, setHasVoted] = useState(field(feedback, 'hasVoted', 'has_voted', false));
const [count, setCount] = useState(field(feedback, 'voteCount', 'vote_count', 0));
const closed =
feedback.status === FeedbackStatus.Completed ||
feedback.status === FeedbackStatus.Rejected;
const onPress = async () => {
try {
const result = hasVoted ? await unvote(feedback.id) : await vote(feedback.id);
setHasVoted(field(result, 'hasVoted', 'has_voted', !hasVoted));
setCount(field(result, 'voteCount', 'vote_count', count));
} catch (error) {
if ((error as FeedbackKitError).code === 'CONFLICT') {
setHasVoted(true);
}
}
};
return (
<Pressable
onPress={() => { void onPress(); }}
disabled={closed || isVoting}
style={{ opacity: closed ? 0.4 : 1, alignItems: 'center', minWidth: 48 }}
>
{isVoting ? <ActivityIndicator /> : <Text>{hasVoted ? '▲ Voted' : '▲'}</Text>}
<Text>{count}</Text>
</Pressable>
);
}
The rules behind it come from the server:
- A new request starts with its submitter's vote, so it shows a count of 1.
- Voting stops at Completed and Rejected. The server answers a vote on those with a
ForbiddenError, which is why the control disables itself. - Each user id votes once per request. A repeat vote is a
ConflictError, with the codeCONFLICT, and the control takes it to mean the user has voted.
Here is how voting looks in FeedbackKit, with sample data from Trailmate:
Votes from inside the app
7. The submit form
1.0 has no ready-made form, so Trailmate writes one with useSubmitFeedback. submit resolves with the created request, so the screen can open it straight away:
import React, { useState } from 'react';
import { Button, Pressable, ScrollView, Text, TextInput, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import {
CategoryBadge,
FeedbackCategory,
useSubmitFeedback
} from 'feedbackkit-react-native';
import type { FeedbackStackParams } from './navigation';
type Props = NativeStackScreenProps<FeedbackStackParams, 'SuggestIdea'>;
export function SuggestIdeaScreen({ navigation }: Props) {
const { submit, isSubmitting, error } = useSubmitFeedback();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [category, setCategory] = useState<FeedbackCategory>(FeedbackCategory.FeatureRequest);
const [email, setEmail] = useState('');
const canSubmit = title.trim() !== '' && description.trim() !== '' && !isSubmitting;
const onSubmit = async () => {
try {
const created = await submit({
title: title.trim(),
description: description.trim(),
category,
userEmail: email.trim() || undefined
});
navigation.replace('Idea', { id: created.id });
} catch {
// The hook keeps the error for the message below.
}
};
return (
<ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}>
<TextInput placeholder="Title" value={title} onChangeText={setTitle} />
<TextInput
placeholder="What should Trailmate do?"
value={description}
onChangeText={setDescription}
multiline
/>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
{Object.values(FeedbackCategory).map((value) => (
<Pressable
key={value}
onPress={() => setCategory(value)}
style={{ opacity: value === category ? 1 : 0.4 }}
>
<CategoryBadge category={value} />
</Pressable>
))}
</View>
<TextInput
placeholder="Email for updates (optional)"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
{error ? <Text style={{ color: '#FF3B30' }}>{error.message}</Text> : null}
<Button title="Submit" onPress={() => { void onSubmit(); }} disabled={!canSubmit} />
</ScrollView>
);
}
The four categories are Feature Request, Bug Report, Improvement and Other, and CategoryBadge draws each in its theme colour. The server trims the title and description and refuses empty ones with a ValidationError, as it does a malformed email. On the Free plan a project takes 10 requests, and the server answers the eleventh with a PaymentRequiredError whose message the form shows.
If filing should be a perk, leave the form out of the navigator for users without it. Reading and voting do not depend on it.
8. The detail screen
The detail screen loads one request with useFeedback and its comments with useComments:
import React, { useState } from 'react';
import { ActivityIndicator, Button, ScrollView, Text, TextInput, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import {
CategoryBadge,
StatusBadge,
useComments,
useFeedback
} from 'feedbackkit-react-native';
import type { FeedbackStackParams } from './navigation';
import { VoteControl } from './VoteControl';
import { field } from './field';
type Props = NativeStackScreenProps<FeedbackStackParams, 'Idea'>;
export function IdeaScreen({ route }: Props) {
const { feedback, isLoading, error } = useFeedback(route.params.id);
const { comments, addComment, isAdding } = useComments(route.params.id);
const [draft, setDraft] = useState('');
if (isLoading) return <ActivityIndicator style={{ marginTop: 32 }} />;
if (error || !feedback) return <Text>{error?.message ?? 'Not found'}</Text>;
const send = () => {
const text = draft.trim();
if (!text) return;
addComment(text).then(() => setDraft(''), () => undefined);
};
return (
<ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}>
<View style={{ flexDirection: 'row', gap: 6 }}>
<StatusBadge status={feedback.status} />
<CategoryBadge category={feedback.category} />
</View>
<Text style={{ fontSize: 22, fontWeight: '600' }}>{feedback.title}</Text>
<Text>{feedback.description}</Text>
<VoteControl feedback={feedback} />
{comments.map((comment) => (
<View key={comment.id}>
{field(comment, 'isAdmin', 'is_admin', false) ? (
<Text style={{ fontWeight: '600' }}>Trailmate team</Text>
) : null}
<Text>{comment.content}</Text>
</View>
))}
<TextInput placeholder="Add a comment" value={draft} onChangeText={setDraft} />
<Button title="Send" onPress={send} disabled={isAdding} />
</ScrollView>
);
}
addComment appends the new comment to the list it returns, so the screen updates without a reload. It also takes a second argument that marks a comment as coming from your team. Never pass it from the app your users run: team replies belong in the admin app.
9. Navigation
The three screens sit in one stack. Here it is with React Navigation, reachable from a tab or a settings row:
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { Button } from 'react-native';
import { IdeasScreen } from './IdeasScreen';
import { IdeaScreen } from './IdeaScreen';
import { SuggestIdeaScreen } from './SuggestIdeaScreen';
export type FeedbackStackParams = {
Ideas: undefined;
Idea: { id: string };
SuggestIdea: undefined;
};
const Stack = createNativeStackNavigator<FeedbackStackParams>();
export function FeedbackStack() {
return (
<Stack.Navigator>
<Stack.Screen
name="Ideas"
component={IdeasScreen}
options={({ navigation }) => ({
title: 'Ideas',
headerRight: () => (
<Button title="Suggest" onPress={() => navigation.navigate('SuggestIdea')} />
)
})}
/>
<Stack.Screen name="Idea" component={IdeaScreen} options={{ title: 'Idea' }} />
<Stack.Screen
name="SuggestIdea"
component={SuggestIdeaScreen}
options={{ title: 'Suggest an idea', presentation: 'modal' }}
/>
</Stack.Navigator>
);
}
Keep the form one tap from the list. People who arrive to ask for offline maps often find the request already there, and vote for it instead of filing a duplicate.
10. Who is voting: MRR
MRR is what turns a vote count into a priority. Pass what each user pays, and your team can sort requests by the total monthly revenue of the people behind them. 1.0 has no hook for it, but useFeedbackKit() returns the JavaScript client, and its users.register takes a monthly amount:
import { useEffect } from 'react';
import { useFeedbackKit, useFeedbackKitContext } from 'feedbackkit-react-native';
type TrailmatePlan = 'monthly' | 'yearly' | null;
export function useReportPlan(plan: TrailmatePlan) {
const client = useFeedbackKit();
const { userId } = useFeedbackKitContext();
useEffect(() => {
if (!userId) return;
const mrr = plan === 'monthly' ? 4.99 : plan === 'yearly' ? 39.99 / 12 : undefined;
client.users.register({ userId, mrr }).catch(() => undefined);
}, [client, userId, plan]);
}
Convert other periods yourself: a yearly price divided by 12, a quarterly one by 3. Leaving mrr out clears a stored amount, which suits a lapsed subscription.
11. Status emails
Telling people what happened to their request is what brings them back. FeedbackKit sends status emails to two groups, under two separate rules:
- The submitter. If they gave an email on the form, they are emailed when their request moves to a status in the project's email list, on every plan. Choosing which statuses are in that list needs Pro.
- Voters. A voter can ask to be told as well. Their opt-in is saved, and they are included in the send, when the project owner is on Team.
The built-in VoteButton sends no email. To let voters opt in, pass the address with the vote. If Trailmate already knows a signed-in user's email and they have agreed to updates, the vote control's call becomes:
await vote(feedback.id, { email: account.email, notifyStatusChange: true });
A Rejected request's reason goes into its email, and a voter's email carries a one-click unsubscribe link. Closing the loop with status emails covers why this matters, and plans are on the pricing page.
12. Theming
The theme prop takes any part of the theme, and whatever you leave out keeps its default. darkTheme is exported for the dark colour scheme, and you can lay your own colours over it:
import React from 'react';
import { useColorScheme } from 'react-native';
import { FeedbackProvider, darkTheme } from 'feedbackkit-react-native';
import { FEEDBACK_API_KEY } from './feedback/config';
const trailmate = { primaryColor: '#2F855A' };
export function ThemedFeedback({ children }: { children: React.ReactNode }) {
const scheme = useColorScheme();
const theme = scheme === 'dark' ? { ...darkTheme, ...trailmate } : trailmate;
return (
<FeedbackProvider apiKey={FEEDBACK_API_KEY} theme={theme}>
{children}
</FeedbackProvider>
);
}
The theme covers primaryColor, backgroundColor, cardBackgroundColor, textColor, secondaryTextColor, borderColor, errorColor, successColor, statusColors, categoryColors, borderRadius (default 12) and spacing (default 8). To recolour statuses or categories, pass the whole map with all its keys. The README's createTheme does not exist; the exported helper is mergeTheme(base, overrides). The badges' labels are in English in this version, and Android users see the same six status names as iOS users: Pending, Approved, In Progress, TestFlight, Completed and Rejected.
13. Testing tips
A wrong key shows an error, not an empty list. The server answers with a 401, the hook's error is an AuthenticationError, and the ready-made list shows its message with Tap to retry. Check the key before anything else.
Keep test requests off the real board. Everything you file while testing lands in the project your key belongs to. Pro includes two projects, so you can give development builds a second project's key with __DEV__ ? DEV_KEY : FEEDBACK_API_KEY. Otherwise, delete test requests in the admin app afterwards.
Start over as a new user. Call setUserId(undefined) from a debug menu. EnsureFeedbackUser then creates and stores a fresh id.
Check both platforms. The components use React Native primitives and draw the same on iOS and Android, but test pull to refresh and the keyboard on the form on each.
Where to go next
Once requests come in, the work moves to your side: triage them in the FeedbackKit admin app on iPhone, iPad or Mac, merge duplicates, and move the ones you build through Approved, In Progress and TestFlight to Completed. Your team gets a push notification for each new request and comment. On Pro and up you can send requests to GitHub, Linear, Notion or another of the trackers FeedbackKit connects to, and post new ones to Slack. The reference for every component and hook is in the React Native SDK docs, and the next release is described in What's New in React Native SDK 1.1.
Every plan includes the React Native SDK. Start free, copy your project key, and the board can be in your next build.