typescriptreact-nativeexporeact-hook-formexpo-router

Save button in expo-router header


I am new to react native and am trying to hook up a save button in a modal header using expo-router. I can get the button to do what I need in the modal itself, but I would like this to be in the header that is set from the expo-router. It doesn't seem right to move the data-saving logic into the routing file, and even when I do I get these realm context errors. So I don't know how to proceed. Is there a way to do what I need with expo? Seems to me like this use case should be common, but I can't seem to find an example anywhere, including their documentation.

_layout.tsx

import React from 'react';
import { RealmProvider } from '@realm/react';
import { router, Stack } from 'expo-router';

import { HeaderButton } from '@components/common/fields/HeaderButton';
import { schemas } from '@models/index';

export default function Layout() {
  return (
    <RealmProvider schema={schemas} deleteRealmIfMigrationNeeded={true}>
      <Stack>
        <Stack.Screen
          name="index"
          options={{
            headerShown: false,
          }}
        />
        <Stack.Screen
          name="modal"
          options={{
            presentation: 'modal',
            headerTitle: () => (
              <HeaderButton displayText="<" handleClick={() => router.back()} />
            ),
            // headerRight: () => (
            //   <HeaderButton
            //     handleClick={}
            //     displayText="Save"
            //   />
            // ),
          }}
        />
      </Stack>
    </RealmProvider>
  );
}

modal.tsx

import React from 'react';
import { StatusBar } from 'expo-status-bar';
import { View } from 'react-native';

import { AddRecipeForm } from '@screens/recipe/AddRecipeForm';

export default function Modal() {
  return (
    <View>
      <StatusBar style="light" />
      <AddRecipeForm />
    </View>
  );
}

AddRecipeForm.tsx

import React, { useCallback } from 'react';
import { useRealm } from '@realm/react';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { StyleSheet, View } from 'react-native';

import { HeaderButton } from '@components/common/fields/HeaderButton';
import { TextField } from '@components/common/fields/TextField';
import { Recipe } from '@models/Recipe';
import colors from '@styles/colors';

interface AddRecipeFormProps {
  userId?: string;
}

interface FormData {
  userId?: string;
  description: string;
  name: string;
}

export const AddRecipeForm: React.FC<AddRecipeFormProps> = ({
  // toggleModal,
  userId,
}) => {
  const { control, handleSubmit } = useForm<FormData>();
  const realm = useRealm();

  const handleAddRecipe = useCallback(
    (name: string, description: string): void => {
      if (!description && !name) {
        return;
      }

      realm.write(() => {
        return realm.create(Recipe, {
          name,
          description,
          userId: userId ?? 'SYNC_DISABLED',
        });
      });
    },
    [realm, userId],
  );

  const onSubmit: SubmitHandler<FormData> = (data) => {
    handleAddRecipe(data.name, data.description);
  };

  return (
    <>
      <View style={styles.container}>
        <View style={styles.buttonContainer}>
          <HeaderButton
            displayText="Save"
            handleClick={handleSubmit(onSubmit)}
          />
          {/* <CustomButton displayText="Cancel" handleClick={toggleModal} /> */}
        </View>
        <Controller
          control={control}
          // rules={{ required: true }}
          render={({ field: { onChange, value } }) => (
            <TextField
              value={value}
              onChangeText={onChange}
              placeholder="name"
            />
          )}
          name="name"
        />
        <Controller
          control={control}
          // rules={{ required: true }}
          render={({ field: { onChange, value } }) => (
            <TextField
              value={value}
              onChangeText={onChange}
              placeholder="description"
            />
          )}
          name="description"
        />
      </View>
    </>
  );
};

tldr; More specifically I want to move the HeaderButton and all of its functionality to be used in the modal header that is defined in the _layout.tsx Stack. It doesn't seem appropriate to move all the logic out of the component and into the layout (nor could I get it to work). Is there a way to acomplish this?


Solution

  • I finally figured it out. I actually figured the expo-router by reading the react-navigation docs. https://reactnavigation.org/docs/header-buttons/#header-interaction-with-its-screen-component

    Essentially you declare it in _layout.tsx and sort of "override" it in the actual component using navigation.setOptions. See the headerRight props in the the two files below.

    _layout.tsx

    export default function Layout() {
      const router = useRouter();
      return (
        <RealmProvider schema={schemas} deleteRealmIfMigrationNeeded={true}>
          <Stack>
            <Stack.Screen
              name="index"
              options={{
                headerShown: false,
              }}
            />
            <Stack.Screen
              name="modal"
              options={{
                presentation: 'modal',
                headerTitle: 'Add Recipe',
                headerLeft: () => (
                  <HeaderButton handleClick={() => router.back()} displayText="<" />
                ),
                headerRight: () => <HeaderButton displayText="Save" />,
              }}
            />
          </Stack>
        </RealmProvider>
      );
    }
    

    AddRecipeForm.tsx

    export const AddRecipeForm: React.FC<AddRecipeFormProps> = ({
      // toggleModal,
      userId,
    }) => {
      const { control, handleSubmit } = useForm<FormData>();
      const realm = useRealm();
      const navigation = useNavigation();
      const router = useRouter();
    
      const handleAddRecipe = useCallback(
        (name: string, description: string): void => {
          if (!description && !name) {
            return;
          }
    
          realm.write(() => {
            return realm.create(Recipe, {
              name,
              description,
              userId: userId ?? 'SYNC_DISABLED',
            });
          });
        },
        [realm, userId],
      );
    
      useEffect(() => {
        navigation.setOptions({
          headerRight: () => (
            <HeaderButton displayText="Save" handleClick={handleSubmit(onSubmit)} />
          ),
        });
      }, [navigation]);
    
      const onSubmit: SubmitHandler<FormData> = (data) => {
        handleAddRecipe(data.name, data.description);
        router.back();
      };
    
      return (
        <View style={styles.container}>
          <Controller
            control={control}
            // rules={{ required: true }}
            render={({ field: { onChange, value } }) => (
              <TextField value={value} onChangeText={onChange} placeholder="name" />
            )}
            name="name"
          />
          <Controller
            control={control}
            // rules={{ required: true }}
            render={({ field: { onChange, value } }) => (
              <TextField
                value={value}
                onChangeText={onChange}
                placeholder="description"
              />
            )}
            name="description"
          />
        </View>
      );
    };
    

    Hope this helps someone, especially with the expo docs being so sparse.