Orchestration SDKs

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:

The node types returned during a journey
Node type Description

ContinueNode

Indicates a step in the middle of the authentication journey that requires input.

Call actions.next(input) to submit the collected payload and advance to the next step.

Can also carry optional page metadata, such as a header and description. See Displaying ContinueNode page metadata.

SuccessNode

Indicates successful authentication.

Access the user object by calling user().

FailureNode

Represents an unexpected failure, such as a network error, a parsing issue, or an internal SDK error.

Access the failure details via node.cause.

ErrorNode

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 node.message.

To complete an authentication journey you must handle each node type in your client application.

Handling different node types in a journey
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:

Normalizing callbacks into form fields

The useJourneyForm hook maps all callbacks to a flat list of typed field.kind values, 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.canSubmit and form.issues tell 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.values from field.defaultValue automatically, so pre-populated server fields just appear.

  • form.meta flags handle any mixed steps. When a step contains a mix of manual fields, FIDO callbacks, and output-only labels, the hasManual, hasIntegrationRequired, and other flags let you conditionally render the right UI sections without examining each callback yourself.

  • Submit payload is built for you. form.input is 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.type names, such as NameCallback,PasswordCallback.

If you have worked with the native Orchestration SDK for Android or iOS previously you will find this pattern immediately recognizable.

Other benefits:

  • No abstraction to learn. There are no normalized kind values or executionMode concepts to understand — the code says exactly what it does.

  • Full control over the payload. You set callback.value directly on the callback object and pass { callbacks } to actions.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 useJourneyForm does not support, explicit handling is the only way forward.

Normalizing callbacks into form fields

To help you handle the required input you can use the useJourneyForm hook.

This hook normalizes the callbacks into a flat list of typed fields, and builds the next() input payload for you.

Collecting user input with useJourneyForm
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:

JourneyNormalizedField properties
Property Type Description

id

string

A stable identifier for the field in the format CallbackType:index.

For example, NameCallback:0.

prompt

string

The label to display to the user.

kind

string

The input kind. One of:

  • text

  • password

  • number

  • boolean

  • choice

  • kba

  • output

  • unknown

executionMode

string

How the field should be handled. One of:

manual

A field to show to the user to obtain input.

auto_capable

A field that can be handled automatically without input from the user.

integration_required

A field that requires a separate integration, such as FIDO or social sign-on.

output_only

A read-only field such as a label or other text. No user input is required.

unsupported

A field that the Orchestration SDK for React Native does not recognize or support.

requiresUserInput

boolean

Is true if the field needs the user to provide a value before the form can be submitted.

required

boolean

Is true if the field must have a non-empty value.

defaultValue

varies

The pre-populated value from the server, if any.

options

JourneyFieldOption[]

The available options to show the user when the kind property is choice or kba.

Each option has an index, label, and value property.

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:

Using form metadata
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
}
All form.meta flags
Flag Description

hasManual

true if at least one field requires direct user input.

hasOutputOnly

true if at least one field is display-only (no input required).

hasAutoCapable

true if at least one field can be handled automatically by the client.

hasIntegrationRequired

true if at least one field requires an external integration (for example, FIDO or a social IdP).

hasUnsupported

true if the step contains a callback type the SDK does not support.

hasRequiredConsentMissing

true if a consent callback is present and the user has not yet accepted.

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:

Reasons preventing form submission
Issue code When it’s raised

NO_ACTIVE_CONTINUE_NODE

Either node is null or not the ContinueNode type.

INTEGRATION_REQUIRED

A callback has an executionMode value of auto_capable or integration_required that needs to be completed.

Callbacks that require integration include those used for FIDO, social login, reCAPTCHA, and PingOne Protect.

UNSUPPORTED_CALLBACK

A callback type the Orchestration SDK doesn’t recognize.

These callbacks have an executionMode value of unsupported.

REQUIRED_CONSENT_MISSING

A callback marked as required is not marked as true.

For example, a TermsAndConditions, ConsentMapping, or BooleanAttributeInput callbacks marked as true: required.

INVALID_VALUE

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 useJourneyForm hook is headless. It manages normalized fields and submit planning, but does not render UI by itself, and it does not auto-run callbacks.

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 });

Handling callbacks explicitly
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;

Displaying ContinueNode page metadata

A Page Node in an authentication journey can carry UI metadata in addition to the callbacks it contains.

You set this metadata on the Page Node when you configure the journey. See the Page node configuration reference for the available properties.

When the server sends a Page Node, the Orchestration SDK surfaces this metadata on the resulting ContinueNode payload so you can use it to render the step:

ContinueNode page metadata properties
Property Type Description

header

string

Header text to display above the form, if the server set one on the Page Node.

A plain string, taken directly from the Page Node’s header field. No localized map support.

description

string

Description text to display below the header, if the server set one on the Page Node.

A plain string, taken directly from the Page Node’s description field. No localized map support.

stage

string

The raw stage value set on the Page Node.

Either a plain stage name, such as Login, or a JSON string that carries localized UI text.

The JSON can contain a submitButtonText and a pageFooter map:

{
  "submitButtonText": {
    "en": "Continue",
    "en-gb": "Sign in"
  },
  "pageFooter": {
    "en": "© 2026 Example Inc, USA",
    "fr": "© 2026 Exemple SA"
  }
}

submitButtonText

string

The submit button text resolved from the stage locale map using the device locale.

Not a Page Node field. The Orchestration SDK derives it from the submitButtonText key in the stage JSON.

pageFooter

string

The page footer text resolved from the stage locale map using the device locale.

Not a Page Node field. The Orchestration SDK derives it from the pageFooter key in the stage JSON.

These properties are optional. They are only populated on ContinueNode payloads; the other node types do not carry them.

Each of these properties is a string. If the server does not provide the underlying value, whether set directly on the Page Node or resolved from the stage JSON, then the property value is set to an empty string. This means that you can treat each one as a plain string without checking its type.

Resolving localized text from the stage locale map

When the Page Node’s stage property is a JSON string, it can carry localized values for the submit button text and the page footer:

Example stage JSON with localized UI text
{
  "submitButtonText": {
    "en": "Continue",
    "en-gb": "Sign in",
    "fr": "Continuer"
  },
  "pageFooter": {
    "en": "© 2026 Example Inc, USA",
    "fr": "© 2026 Exemple SA",
    "fr-CA": "© 2026 Example Corp, Canada"
  }
}

The Orchestration SDK resolves submitButtonText and pageFooter text values from this map by comparing the map key against the locale set on the device, in the following order:

  1. If the map contains a single value, that value is used regardless of the device locale.

  2. If there are multiple values in a map, the Orchestration SDK attempts a text match with the following process:

    • The device locale identifier is always converted to lowercase.

    • The SDK creates a variant of the device locale to attempt a match, with hyphens replaced by underscores.

    • Map keys are compared as written and are not modified in any way.

    Text match examples using the submitButtonText map
    Device locale Map key Match? Why

    en-GB

    EN-GB

    No

    Map keys are not modified before comparison, so a key that is not lowercase never matches a device locale identifier, which is always converted to lowercase.

    en-GB

    en-gb

    Yes

    The device identifier is converted to the lowercase form en-gb, which matches the key as written.

    en-GB

    en_gb

    Yes

    The SDK also tries the device identifier with hyphens replaced by underscores, so a key written en_gb matches a hyphenated device locale.

    en_GB

    en-gb

    Yes

    On Android, this combination does match, because Android converts the device locale to the hyphenated BCP-47 form en-gb before matching.

    No

    On iOS, the device identifier is the underscore form en_gb, and the SDK never generates a hyphenated variant, so the key en-gb is never a match.

  3. A language-only match.

    Language-only match examples using the pageFooter map
    Device locale Map key Match? Why

    en-CA

    en

    Yes

    No exact match exists, so only the language part of the device locale is compared.

    fr-CA

    en

    No

    The language part of the device locale, fr, differs from the map key en.

  4. The value selected when nothing previously matches depends on the platform:

    Android

    Consistently uses the first entry in the stage JSON, so the selection is deterministic.

    iOS

    Does not guarantee an order, so the selected value might be different on each execution of the app.

Because of these rules, we recommend that you write your map keys in lowercase, and use underscores as the separator between language and region subtags, such as en_gb.

  • Underscore keys match in every device locale configuration on both Android and iOS.

  • Hyphenated keys such as en-gb always match on Android, but on iOS they can only match when the device locale identifier itself is hyphenated, which is uncommon.

When the stage property is absent, is not valid JSON, or has no localized map for that key, submitButtonText and pageFooter are empty strings.

Rendering ContinueNode page metadata
const [node, actions] = useJourney();
const form = useJourneyForm(node);

if (node?.type === 'ContinueNode') {
  return (
    <View>
      {node.header ? <Text>{node.header}</Text> : null}

      {node.description ? <Text>{node.description}</Text> : null}

      {/* Render the callbacks as before */}

      <Button
        title={node.submitButtonText || 'Continue'}
        disabled={!form.canSubmit}
        onPress={() => actions.next(form.input)}
      />

      {node.pageFooter ? <Text>{node.pageFooter}</Text> : null}
    </View>
  );
}

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.cause 1.

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 4xx or 5xx status 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.message 2, and access the raw JSON response by using node.input 3.

Handling error and failure node types in a journey
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.