Navigating an authentication journey in React Native
PingOne Advanced Identity Cloud PingAM React Native
Each step in an authentication journey, including the first, returns a JourneyNode object representing where you are in the journey.
The node has a type property that indicates the current state:
| Node type | Description |
|---|---|
|
Indicates a step in the middle of the authentication journey that requires input. Call |
|
Indicates successful authentication. Access the user object by calling |
|
Represents an unexpected failure, such as a network error, a parsing issue, or an internal SDK error. Access the failure details via |
|
Indicates a server-side validation or request error, such as invalid credentials or exceeding the maximum number of login attempts. Access the error message via |
To complete an authentication journey you must handle each node type in your client application.
import { useJourney } from '@ping-identity/rn-journey';
function LoginScreen() {
const [node, actions] = useJourney();
useEffect(() => {
actions.start('sdkUsernamePasswordJourney');
}, []);
if (actions.loading) {
// Show a loading indicator while the native module is processing
}
if (node?.type === 'ContinueNode') {
// Render input fields for node.callbacks
}
if (node?.type === 'SuccessNode') {
// Authentication succeeded — navigate to the home screen
}
if (node?.type === 'FailureNode') {
// Display node.cause to the user or a generic error message
}
if (node?.type === 'ErrorNode') {
// Display node.message to the user
}
}
Handling ContinueNode
For ContinueNode steps, the server sends a list of callbacks that require user input.
There are two ways of handling the gathering of the required input:
- Normalising callbacks into form fields
-
The
useJourneyFormhook maps all callbacks to a flat list of typedfield.kindvalues, such as text, password, boolean, choice, and so on, so your UI is driven by what kind of input is needed, not which specific callback the server sent.If the server journey is reconfigured to swap one callback type for another that maps to the same kind, the UI keeps working without a code change.
Other benefits:
-
Validation is built in.
form.canSubmitandform.issuestell you whether the step is ready to submit and why not, so you don’t have to write your own required-field checks. -
Default values are pre-seeded. The hook hydrates
form.valuesfromfield.defaultValueautomatically, so pre-populated server fields just appear. -
form.metaflags handle any mixed steps. When a step contains a mix of manual fields, FIDO callbacks, and output-only labels, thehasManual,hasIntegrationRequired, and other flags let you conditionally render the right UI sections without examining each callback yourself. -
Submit payload is built for you.
form.inputis the ready-to-send payload, so you never assemble the callback array manually. -
Less boilerplate. The code is shorter and doesn’t grow linearly as the number of callback types increases.
-
- Handling callbacks explicitly by type
-
You work directly with callback objects using the same
callback.typenames, such asNameCallback,PasswordCallback.If you have worked with the native Orchestration SDK for Android or iOS previously you will find this pattern immediately recognisable.
Other benefits:
-
No abstraction to learn. There are no normalised kind values or
executionModeconcepts to understand — the code says exactly what it does. -
Full control over the payload. You set
callback.valuedirectly on the callback object and pass{ callbacks }toactions.next(). What you send is exactly what you set, with no intermediary building the payload. -
Easier to handle unusual or custom callbacks. If a journey uses a non-standard callback that
useJourneyFormdoes not support, explicit handling is the only way forward.
-
Normalising callbacks into form fields
To help you handle the required input you can use the useJourneyForm hook.
This hook normalises the callbacks into a flat list of typed fields, and builds the next() input payload for you.
const [node, actions] = useJourney();
const form = useJourneyForm(node);
if (actions.loading) return <ActivityIndicator />;
if (node?.type === 'ContinueNode') {
return (
<View>
{form.fields
.filter(field => field.requiresUserInput)
.map(field => {
if (field.kind === 'boolean') {
return (
<Switch
key={field.id}
value={Boolean(form.values[field.id] ?? false)}
onValueChange={val ⇒ form.setValue(field.id, val)}
/>
);
}
if (field.kind === 'choice' && field.options) {
return (
<View key={field.id}>
{field.options.map(option => (
<Button
key={option.index}
title={option.label}
onPress={() => form.setValue(field.id, option.index)}
/>
))}
</View>
);
}
return (
<TextInput
key={field.id}
placeholder={field.prompt}
secureTextEntry={field.kind === 'password'}
value={String(form.values[field.id] ?? '')}
onChangeText={text => form.setValue(field.id, text)}
/>
);
})}
<Button
title="Continue"
disabled={!form.canSubmit}
onPress={() => actions.next(form.input)}
/>
</View>
);
}
if (node?.type === 'SuccessNode') {
return (
<View>
<Text>Authentication succeeded.</Text>
<Button title="Sign out" onPress={() => actions.logoutUser()} />
</View>
);
}
if (node?.type === 'ErrorNode') {
return (
<View>
<Text>{node.message}</Text>
<Button title="Try again" onPress={() => actions.start('sdkUsernamePasswordJourney')} />
</View>
);
}
if (node?.type === 'FailureNode') {
return (
<View>
<Text>Something went wrong. Please try again.</Text>
<Button title="Retry" onPress={() => actions.start('sdkUsernamePasswordJourney')} />
</View>
);
}
return null;
The useJourneyForm hook returns form.fields, which is a list of JourneyNormalizedField types, each having the following properties:
| Property | Type | Description |
|---|---|---|
|
|
A stable identifier for the field in the format For example, |
|
|
The label to display to the user. |
|
|
The input kind. One of:
|
|
|
How the field should be handled. One of:
|
|
|
Is |
|
|
Is |
|
varies |
The pre-populated value from the server, if any. |
|
|
The available options to show the user when the Each option has an |
Checking the composition of the form
The useJourneyForm hook provides the form.meta property. This provides flags that describe the composition of the current step, which you can use to conditionally render UI:
const form = useJourneyForm(node);
// Only render a submit button when there are manual fields
if (form.meta.hasManual) {
// Show input fields and a submit button
}
// Inform the user an integration such as FIDO is needed
if (form.meta.hasIntegrationRequired) {
// Show an informational message
}
| Flag | Description |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Checking if the required data is complete
Use the form.canSubmit boolean to check that each callback has been handled appropriately. If the boolean is false, you can check form.issues to list the reasons.
Possible reasons that you could not submit the form include the following:
| Issue code | When it’s raised |
|---|---|
|
Either |
|
A callback has an Callbacks that require integration include those used for FIDO, social login, reCAPTCHA, and PingOne Protect. |
|
A callback type the Orchestration SDK doesn’t recognise. These callbacks have an |
|
A callback marked as required is not marked as For example, a |
|
Field validation has failed. For example, a required field is empty, a number field has a non-finite value, a choice field has an out-of-range index, or a KBA field is missing either the question or answer. |
|
The |
Handling callbacks explicitly by type
If you prefer you can handle each callback type explicitly and work directly with node.callbacks.
You’ll need to build up the callback response yourself, for example setting the value the user enters into a form field, and return the complete data to the server by using actions.next({ callbacks });
const [node, actions] = useJourney();
if (actions.loading) return <ActivityIndicator />;
if (node?.type === 'ContinueNode') {
const callbacks = node.callbacks ?? [];
return (
<View>
{callbacks.map((callback, index) => {
if (callback.type === 'NameCallback') {
return (
<TextInput
key={index}
placeholder={callback.prompt}
onChangeText={text => {
callback.value = text;
}}
/>
);
}
if (callback.type === 'PasswordCallback') {
return (
<TextInput
key={index}
placeholder={callback.prompt}
secureTextEntry
onChangeText={text => {
callback.value = text;
}}
/>
);
}
return null;
})}
<Button
title="Continue"
onPress={() => actions.next({ callbacks })}
/>
</View>
);
}
if (node?.type === 'SuccessNode') {
return (
<View>
<Text>Authentication succeeded.</Text>
<Button title="Sign out" onPress={() => actions.logoutUser()} />
</View>
);
}
if (node?.type === 'ErrorNode') {
return (
<View>
<Text>{node.message}</Text>
<Button title="Try again" onPress={() => actions.start('sdkUsernamePasswordJourney')} />
</View>
);
}
if (node?.type === 'FailureNode') {
return (
<View>
<Text>Something went wrong. Please try again.</Text>
<Button title="Retry" onPress={() => actions.start('sdkUsernamePasswordJourney')} />
</View>
);
}
return null;
Handling FailureNode and ErrorNode
The Journey module distinguishes between the FailureNode and ErrorNode types for different categories of errors encountered during the authentication flow.
FailureNode-
Indicates an unexpected issue that prevents the journey from continuing. This could stem from network problems or data parsing errors.
You can access the underlying error using
node.cause1.You should display a user-friendly generic error message and log the details for support investigation.
ErrorNode-
Signifies an error response from the authentication server, typically an HTTP
4xxor5xxstatus code.These errors often relate to invalid user input or issues server-side.
You can retrieve the specific error message provided by the server using
node.message2, and access the raw JSON response by usingnode.input3.
import { useJourney } from '@ping-identity/rn-journey';
function LoginScreen() {
const [node, actions] = useJourney();
useEffect(() => {
actions.start('sdkUsernamePasswordJourney');
}, []);
if (actions.loading) {
// Show a loading indicator while the native module is processing
}
if (node?.type === 'ContinueNode') {
// Render input fields for node.callbacks
}
if (node?.type === 'SuccessNode') {
// Authentication succeeded — navigate to the home screen
}
if (node?.type === 'FailureNode') {
// Display a user-friendly message to the user
const cause = node.cause; (1)
console.error('Authentication failed:', cause);
}
if (node?.type === 'ErrorNode') {
// Display a user-friendly message to the user
const message = node.message; (2)
const rawInput = node.input; (3)
console.error('Journey error:', message);
console.error('Server response:', JSON.stringify(rawInput));
}
}
Handling SuccessNode
When the client reaches the SuccessNode type it securely stores the session token. However, the SuccessNode does not carry token data inline.
Call actions.user() or actions.ssoToken() explicitly to retrieve session information. This keeps sensitive token data out of SDK-managed state until your app requests it.
import { useJourney } from '@ping-identity/rn-journey';
function LoginScreen() {
const [node, actions] = useJourney();
useEffect(() => {
actions.start('sdkUsernamePasswordJourney');
}, []);
useEffect(() => {
if (node?.type !== 'SuccessNode') return;
async function fetchSession() {
const user = await actions.user();
const session = await actions.ssoToken();
}
void fetchSession();
}, [node]);
if (actions.loading) {
// Show a loading indicator while the native module is processing
}
if (node?.type === 'ContinueNode') {
// Render input fields for node.callbacks
}
if (node?.type === 'FailureNode') {
// Display node.cause to the user or a generic error message
}
if (node?.type === 'ErrorNode') {
// Display node.message to the user
}
}
When the journey completes successfully, see Managing sessions and tokens in React Native for how to retrieve the user object and manage tokens.