How to Add a Feedback Board to a React Native App
Last updated
This guide covers the React Native SDK 1.0.1 build from npm. Later releases, tagged 1.1.0, 1.1.1, 1.2.0 and 2.1.0, exist as git tags on the SDK repository but are not on npm yet, so npm install feedbackkit-react-native gives you 1.0.1, together with feedbackkit-js 1.0.1. The changelog sums up what 1.0 contains and what the tagged releases change. This page is the walkthrough for the registry build: what it does today, and how to ship a board on it, navigation, MRR and testing included.
The 1.0.1 defect to know about. The FeedbackKit API names fields in snake_case, and the JavaScript SDK 1.0.1 that this package is built on passes them through unchanged. So on each request, voteCount, hasVoted, commentCount and createdAt are undefined at runtime, while the id, title, description, status and category are fine. The ready-made VoteButton therefore shows no count and never shows the voted state: a second tap sends another vote, which the server refuses as a repeat, and the button swallows that error. Cards from FeedbackCard hide their comment count for the same reason. React Native SDK 1.1.0, paired with JavaScript SDK 1.1.0, fixes this; on 1.0.1, this guide reads the snake_case fields with a small helper and draws its own vote control.
By the end of this guide your app has a screen where users read the feature requests other people have filed, upvote the ones they care about, comment, and submit their own, on iOS and Android. It is a public, votable board, not a support inbox and not a crash reporter. The examples use Trailmate, a fictional hiking app whose users keep asking for offline maps.
Install
The React Native SDK is a layer on top of the JavaScript SDK, and it keeps the user id in AsyncStorage. All three are peer dependencies, so install them together:
npm install feedbackkit-react-native feedbackkit-js @react-native-async-storage/async-storage
1.0.1 needs React 18 or later, React Native 0.70 or later and AsyncStorage 1.17 or later. On bare React Native, run pod install in the ios folder for AsyncStorage. In Expo, add it with npx expo install @react-native-async-storage/async-storage. The FeedbackKit packages have nothing native to link.
If your project type-checks strictly: 1.0.1'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"]
}
}
}
Configure the provider
Everything hangs off one context provider. Put it above 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 { EnsureFeedbackUser } from './feedback/EnsureFeedbackUser';
import { RootStack } from './navigation';
export default function App() {
return (
<FeedbackProvider apiKey={FEEDBACK_API_KEY} theme={{ primaryColor: '#2E7D32' }}>
<EnsureFeedbackUser />
<NavigationContainer>
<RootStack />
</NavigationContainer>
</FeedbackProvider>
);
}
1.0.1's provider takes five props: apiKey and children, which are required, and userId, theme and baseUrl, which are optional. The default baseUrl reaches FeedbackKit's production server, where projects made in the admin app live, so leave it unset. theme is a partial object: override primaryColor and keep the rest, or pass the exported darkTheme. 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 project API key is a client-side credential by design. It travels as an X-API-Key header, it is scoped to one project, and it covers end-user actions: reading, filing, voting and commenting. It is not your admin login, so shipping it inside the app is the intended use. Treat it as configuration all the same: read it from your build-time config layer (Expo's public environment variables, react-native-config, whatever you already use) and import it from one module, so a rotation touches one file. Regenerating the key in project settings stops every shipped build that still uses the old one, so plan a rotation around a release.
Give the provider a user id
Voting, submitting and commenting all need a user id, and 1.0.1 does not create one. Without it the board still loads, and vote, submit and addComment throw "User ID is required". That error is the one people hit.
If your app has accounts, pass your account id as the userId prop. 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 under @feedbackkit/userId. At the next launch the provider reads it back, and the hooks wait for that read before loading:
// feedback/EnsureFeedbackUser.tsx
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.
Read the counts with a helper
As the note at the top explains, a request's counts arrive on 1.0.1 as vote_count, has_voted and comment_count, and a comment's team flag as is_admin. This helper reads the camelCase name and falls back to the snake_case one, so the screens below keep working on a release that converts the keys:
// feedback/field.ts
export function field<T>(item: object, camel: string, snake: string, fallback: T): T {
const raw = item as Record<string, unknown>;
return (raw[camel] ?? raw[snake] ?? fallback) as T;
}
Render the board
The ready-made FeedbackList loads the requests and draws a card for each, with pull to refresh, a floating add button when showAddButton and onAddPress are set, and a callback on tap. On 1.0.1 its cards include the built-in VoteButton with the count missing, as described above. Votes cast through it do reach the server, but it cannot show them or take them back. So build the list from the same hook, with a vote control that reads the counts:
// feedback/VoteControl.tsx
import React, { useState } from 'react';
import { ActivityIndicator, Pressable, Text } from 'react-native';
import {
ConflictError,
FeedbackStatus,
useVote,
type Feedback
} 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 instanceof ConflictError) {
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>
);
}
// feedback/IdeasScreen.tsx
import React, { useEffect } from 'react';
import { FlatList, Pressable, Text, View } from 'react-native';
import { FeedbackStatus, StatusBadge, useFeedbackList } from 'feedbackkit-react-native';
import { VoteControl } from './VoteControl';
import { field } from './field';
type Nav = {
navigate: (screen: string, params?: { id: string }) => void;
addListener: (event: 'focus', callback: () => void) => () => void;
};
export function IdeasScreen({ navigation }: { navigation: Nav }) {
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={{ flexDirection: 'row', gap: 12, padding: 16 }}
onPress={() => navigation.navigate('Idea', { id: item.id })}
>
<View style={{ flex: 1, gap: 4 }}>
<StatusBadge status={item.status} size="small" />
<Text style={{ fontSize: 16, fontWeight: '600' }}>{item.title}</Text>
<Text>{field(item, 'commentCount', 'comment_count', 0)} comments</Text>
</View>
<VoteControl feedback={item} />
</Pressable>
)}
/>
);
}
status: FeedbackStatus.Approved is the decision worth thinking about. Leave it out and users see everything, including pending items nobody on your team has read yet. With it, the board is curated: nothing appears until you approve it. The focus listener reloads the list when the user comes back from the form or a detail screen. useFeedbackList accepts status, category and includeMerged; 1.0.1 has no sort option, and the server returns the most-voted requests at the top.
Voting
The rules behind the vote control 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. A vote on those is a
ForbiddenError, which is why the control disables itself. - Each user id votes once per request. A repeat vote is a
ConflictError, 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
A voter who wants an email when the status changes needs an address and an opt-in. useVote takes both:
const { vote } = useVote();
await vote(feedbackId, { email: 'hiker@example.com', notifyStatusChange: true });
The server saves that opt-in when the project owner is on the Team plan; below Team the vote still counts and the opt-in is dropped. Ask for the address in your own UI, and send the opt-in when the person has ticked a box saying they want these emails.
Submit and detail screens
1.0.1 has no ready-made form or detail screen, so both are built from hooks. submit resolves with the created request:
import React, { useState } from 'react';
import { Button, Text, TextInput, View } from 'react-native';
import { FeedbackCategory, useSubmitFeedback } from 'feedbackkit-react-native';
export function SuggestIdeaScreen({ navigation }: {
navigation: { replace: (screen: string, params: { id: string }) => void };
}) {
const { submit, isSubmitting, error } = useSubmitFeedback();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [email, setEmail] = useState('');
const onSubmit = async () => {
try {
const created = await submit({
title: title.trim(),
description: description.trim(),
category: FeedbackCategory.FeatureRequest,
userEmail: email.trim() || undefined
});
navigation.replace('Idea', { id: created.id });
} catch {
// The hook keeps the error for the message below.
}
};
return (
<View style={{ padding: 16, gap: 12 }}>
<TextInput placeholder="Title" value={title} onChangeText={setTitle} />
<TextInput placeholder="What should Trailmate do?" value={description} onChangeText={setDescription} multiline />
<TextInput placeholder="Email for updates (optional)" value={email} onChangeText={setEmail} autoCapitalize="none" />
{error ? <Text>{error.message}</Text> : null}
<Button title="Submit" onPress={() => { void onSubmit(); }} disabled={isSubmitting || title.trim() === ''} />
</View>
);
}
The four categories are FeatureRequest, BugReport, Improvement and Other, and CategoryBadge draws each one. The server refuses an empty title or description, and a malformed email, with a ValidationError whose message the form shows.
The detail screen loads one request with useFeedback and its comments with useComments:
import React, { useState } from 'react';
import { Button, ScrollView, Text, TextInput } from 'react-native';
import { StatusBadge, useComments, useFeedback } from 'feedbackkit-react-native';
import { VoteControl } from './VoteControl';
import { field } from './field';
export function IdeaScreen({ route }: { route: { params: { id: string } } }) {
const { feedback, isLoading, error } = useFeedback(route.params.id);
const { comments, addComment, isAdding } = useComments(route.params.id);
const [draft, setDraft] = useState('');
if (isLoading) return <Text>Loading</Text>;
if (error || !feedback) return <Text>{error?.message ?? 'Not found'}</Text>;
const send = () => {
const text = draft.trim();
if (text) addComment(text).then(() => setDraft(''), () => undefined);
};
return (
<ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}>
<StatusBadge status={feedback.status} />
<Text style={{ fontSize: 22, fontWeight: '600' }}>{feedback.title}</Text>
<Text>{feedback.description}</Text>
<VoteControl feedback={feedback} />
{comments.map((comment) => (
<Text key={comment.id}>
{field(comment, 'isAdmin', 'is_admin', false) ? 'Trailmate team: ' : ''}
{comment.content}
</Text>
))}
<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. Its optional second argument marks a comment as coming from your team. Never pass it from the app your users run: team replies belong in the admin app. There is also useFeedbackKit(), which hands you the underlying client for a call the hooks do not wrap.
Wire the screens into a stack
The three screens sit in one stack, reachable from a tab or a settings row. Here it is with React Navigation's native stack, with the form presented as a modal:
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>
);
}
With the stack in place, the hand-written navigation and route prop types in the screens above can become NativeStackScreenProps<FeedbackStackParams, 'Ideas'> and so on. 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.
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.1 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.
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). The exported mergeTheme(base, overrides) combines two themes the same way the provider does. The badges' labels are in English in this version, and Android users see the same six status names as iOS users.
Where to mount it
Three patterns cover nearly every app:
- A settings row. The default choice. A "Feature requests" row in your settings list pushing the board onto the existing stack. No navigation surgery, and the board stays discoverable without competing for attention.
- A modal. Good when feedback is contextual: a "suggest an improvement" affordance on the screen the user is complaining about. Put the submit screen in a modal group and choose the category to match.
- A tab. Worth it when community input is part of what your app is. A permanent tab is a promise that the board is active.
Whichever you choose, keep the provider at the root. Mounting it per screen rebuilds the client on every navigation and repeats the stored-id read it performs on mount.
What happens after someone taps Submit
A submission arrives as pending, with its submitter's vote already counted. From there your team moves it through the six statuses in the admin app. Two of them close voting:
| Status | Voting |
|---|---|
| Pending | Open |
| Approved | Open |
| In Progress | Open |
| TestFlight | Open |
| Completed | Closed |
| Rejected | Closed |
Once something is worth building, push it to where you actually work. On Pro and up, FeedbackKit creates tickets in GitHub, Notion, ClickUp, Linear, Monday.com, Trello, Airtable, Asana and Basecamp, and posts notifications to Slack. Capabilities are not identical across providers: most sync status and comments, while GitHub syncs status with no comment sync. Check the integrations page before you commit to a provider.
Testing tips
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.
Troubleshooting
- "User ID is required to vote / submit feedback." No user id is set. Add the
EnsureFeedbackUsercomponent above, or passuserIdto the provider. - Vote counts are blank and the vote button never lights up. That is the 1.0.1 snake_case defect in the built-in
VoteButton. Use the vote control above. - Everything fails with an
AuthenticationError. The key is empty, often a config module that resolved toundefinedat build time, or it belongs to a project whose key was regenerated. The hooks'errorcarries it, and the ready-madeFeedbackListshows its message with Tap to retry. - A
NetworkErroron debug builds. The request never completed. If it happens on Android against a local server, look at your cleartext-traffic policy. CORS is a browser rule and does not apply here. - The list is empty and you are sure it should not be. Check the
statusfilter: freshly submitted items sit atpendingand do not appear on an Approved board until you approve them. - A submission succeeds but nobody else sees it. On the Free plan a project shows 10 requests. Past that the write still succeeds, with no 402: the request is saved, its submitter still sees it, and everyone else's list leaves it out. The plan comparison lists what each tier includes.
Next steps
The full prop and hook reference lives in the React Native SDK docs, and the OpenAPI reference covers the endpoints underneath. If your React Native app is one of several clients, the JavaScript guide and the docs index cover the others: the same project, board and votes back every one of them. Seed the board with a few real items before you ship it, and reply to the earliest submissions. An empty board reads as abandoned.
Every plan includes the React Native SDK. Start free, copy your project key, and the board can be in your next build.