Jakallergis.com Blog

Personal thoughts and notes of a software engineer.

Five ways to prompt your user for input in React Native

25/01/2019☕☕ 12 Min Read — In React Native

TL;DR: Use a prompts screen in a modal navigator, wrapped in a promise that pops when resolved.

Let’s say you want to prompt the user to make a specific selection in your react-native app.

Consider the following Component:

import { StyleSheet, View, Text, TouchableOpacity } = 'react-native';
// ...
class SomeScreenComponent extends React.Component<Props> {
    // ...
    promptUser = () => {} // How would you implement this?    // ...
    render() {
        return (
            <View style={styles.container}>
                <Text>{this.state.userSelection}</Text>
                <TouchableOpacity onPress={this.promptUser}>                    <View style={styles.promptButton}>
                        <Text>Choose selection</Text>
                    </View>
                </TouchableOpacity>
            </View>
        );
    }
}

How should we implement the prompt in this.promptUser?

1. Alert

A quick search with Google, and you can easily find react native’s Alert API:

Launches an alert dialog with the specified title and message.

Optionally provide a list of buttons. Tapping any button will fire the respective onPress callback and dismiss the alert. By default, the only button will be an ‘OK’ button.

Alert.alert(title, message, buttons);

So this is pretty much straight forward and it would look somewhat like the following example:

import { StyleSheet, View, Text, TouchableOpacity, Alert } = 'react-native';// ...
class SomeScreenComponent extends React.Component<Props> {
    // ...
    promptUser = () => {        const title = 'Time to choose!';        const message = 'Please make your selection.';        const buttons = [            { text: 'Cancel', type: 'cancel' },            { text: 'Option A', onPress: () => this.setState({userSelection: 'Option A'}) },            { text: 'Option B', onPress: () => this.setState({userSelection: 'Option B'}) }        ];        Alert.alert(title, message, buttons);    }    // ...
    render() {
        return (
            <View style={styles.container}>
                <Text>{this.state.userSelection}</Text>
                <TouchableOpacity onPress={this.promptUser}>
                    <View style={styles.promptButton}>
                        <Text>Select an Option</Text>
                    </View>
                </TouchableOpacity>
            </View>
        );
    }
}

Now, although this would work just fine, there are some caveats to consider.

  1. This only works when the input you want from the user is very specific, like those two options we used in our example. If you wanted the user to enter some text, well, that can’t happen with Alert.
  2. It also works only when you want the user to make ONE selection. You could not use it for a multiple selection feature.
  3. This isn’t cross platform unless you use a maximum of 3 buttons. Should you use more, then that would not work on Android.

2. Alert in a Promise with Async / Await

We could even wrap the Alert in a promise and use buttons that resolve or reject that promise:

// ..
async promptUser = () => {    const selection = await new Promise((resolve) => {        const title = 'Time to choose!';
        const message = 'Please make your selection.';
        const buttons = [            { text: 'Cancel', onPress: () => resolve(null) },            { text: 'Option A', onPress: () => resolve('Option A') },            { text: 'Option B', onPress: () => resolve('Option B') }        ];        Alert.alert(title, message, buttons);
    })
    
    if (selection) {        this.setState({ userSelection: selection });    }}
// ..

This is much better, as it allows for more control over the user’s input. We can now branch out to different logic flows based on the user’s selection with a much cleaner way. In the context of an async function you also get some synchronous-looking functionality.

As long as we respect the asynchronous nature that promptUser now has, this is a great solution, but we still have all the downsides of using Alert mentioned previously.

I don’t like Alert

One more thing about using Alert. How often do you use the alert(); function in your regular JavaScript code?

Yeah, never.

I might be totally mistaken here, but I feel that React Native’s Alert.alert(); is pretty much the same and that we should implement our own modals that are flexible enough to fit our needs and match our design.

Bare with me though, we’ll get there!

3. Custom prompt component

Now let’s say you actually do want more options for the user. We need a component to host the selections.

import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
// ...
type Props = {
    title: string,
    options: string[],
    onSubmit: (selection: string) => void,
    onCancel: () => void
}

type State = {
    selection: string
}

class CustomPromptComponent extends React.PureComponent<Props, State> {
    constructor(props: Props) {
        super(props);
        this.state = {
            selection: null
        };
    }
    // ...
    _onCancelPress = () => this.props.onCancel();

	_onSubmit = () => this.props.onSubmit(this.state.selection);

	_onOptionPressed = (option: string) => this.setState({ selection: option })
    // ...
    _renderOption = (option: string) => {
        const { selection } = this.state;
        const isSelected = selection === option;
        // ...
    }
    
    render() {
        return (
            <View style={styles.container}>
                <Text style={styles.title}>{this.props.title}</Text>
                {this.props.options.map(this._renderOption)}
                <View>
                    <TouchableOpacity onPress={this._onCancelPress}>
                        <Text>Cancel</Text>
                    </TouchableOpacity>
                    <TouchableOpacity onPress={this._onSubmitPress}>
                        <Text>OK</Text>
                    </TouchableOpacity>
                </View>
            </View>
        );
    }
}

const styles = StyleSheet.create({
    container: {
        position: 'absolute',
        // ...
        // ...
    },
    title: {
        //...
    }
})

OK, now that we have our custom prompt component, we have to add it into our screen component, and make sure it’s the topmost child so that it covers everything when visible:

import { StyleSheet, View, Text, TouchableOpacity } = 'react-native';
// ...
type State = {    showsPrompt: boolean,    userSelection: ?string}class SomeScreenComponent extends React.Component<Props, State> {
	constructor(props: Props) {
        super(props);
        this.state = {            showsPrompt: false,            userSelection: null        };	}
    // ...
    promptUser = () => this.setState({ showsPrompt: true });    // ...
    _renderCustomPrompt() {    	if (!this.state.showsPrompt) {            return null;    	}        return (        <CustomPromptComponent        	title='Select your mood!'        	options={['😀', '😐', '😬', '😲', '😡']}        	onSubmit={userSelection => this.setState({ userSelection, showsPrompt: false })}        	onCancel={() => this.setState({showsPrompt: false})} />        )    }    
    render() {
        return (
            <View style={styles.container}>
	            {this._renderCustomPrompt()}                <Text>{this.state.userSelection}</Text>
                <TouchableOpacity onPress={this.promptUser}>
                    <View style={styles.promptButton}>
                        <Text>Select an Option</Text>
                    </View>
                </TouchableOpacity>
            </View>
        );
    }
}

This starts to get more flexible in terms of how many options we can provide to the user, but still, we are coupled to the component’s state, and this can get ugly.

We need to do this.setState({ showsPrompt: true }) in order for the CustomPromptComponent to show up. Control of flow is changed and now onSubmit and onCancel props of CustomPromptComponent are responsible of what is to be done next.

This is bad firstly because we trigger a bunch of state changes - AKA component re-renders - just to show / hide a prompt, even though nothing essential in bussiness logic has changed. It’s also bad because we lose control of flow and our code gets harder to read and to reason about.

Additionally, It can get really overwhelming if we need that prompt for more than one kind of options.

Consider this:

In the example above we passed an options prop to the CustomPromptComponent that doesn’t change. What if we needed different kinds of options based on what input the user is currently on?

Say we need a prompt for the user to choose between various mood emojis, and a prompt for the user to choose between various ages.

  1. We would need a state property that would hold the options we are going to use for the next prompt,
  2. a state property that would hold the next prompt’s title,
  3. and another property that would indicate what kind of prompt we just submitted / cancelled so that onSubmit and onCancel props could know what exactly to do with the user’s input.

And all that just for 2 kinds of prompts! Pretty messy huh?

4a. A modal screen component to handle multiple custom prompt components!

Let’s create a component called PromptsHandlerScreen that accepts the navigation prop and has the following state:

  • a promptComponent to render. It should accept onSubmit and onCancel props

  • a componentProps to pass to the rendered promptComponent

  • a resolve callback prop that will be used inside onSubmit and onCancel props to handle data

These will be passed through navigation params, straight into the component’s state.

const defaultState = {
    promptComponent: null,
    componentProps: null,
    resolve: () => {}
}

type Props = {
    navigation: any,
    screenProps: any
}

type State = {
    promptComponent: any,
    componentProps: any,
    resolve: (any) => void
}

class PromptsHandlerScreen extends React.PureComponent<Props, State> {
    constructor(props: Props) {
        super(props);
        const promptComponent = this.props.navigation.getParam('promptComponent', null);
        const componentProps = this.props.navigation.getParam('componentProps', null);
        const resolve = this.props.navigation.getParam('resolve', () => {});
        this.state = {
            promptComponent,
            componentProps,
            resolve
        }
    }
	// ...
	_onSubmit = (data: any): void => {
    	const { resolve } = this.state;
        this._resetState(() => {
            // This should be in a timer
            // like setTimeout with something
            // like 250ms or so, to avoid possible
            // ui issues, or to give your
            // components the flexibility to
            // animated on mount as well as
            // on unmount.
            // Otherwise we could completely
            // skip reseting the state;
            this.props.navigation.pop();
            resolve({ success: true, data });
        });
	};

	_onCancel = (): void => {
    	const { resolve } = this.state;
        this._resetState(() => {
            // This should be in a timer
            // like setTimeout with something
            // like 250ms or so, to avoid possible
            // ui issues, or to give your
            // components the flexibility to
            // animated on mount as well as
            // on unmount.
            // Otherwise we could completely
            // skip reseting the state;
            this.props.navigation.pop();
            resolve({ success: false });
        });
	};

	_resetState(cb: () => void): void {
    	this.setState({ ...defaultState }, () => cb());
	}
	// ...
	render() {
    	const { promptComponent: CurrentComponent, componentProps } = this.state;
        return (
            <CurrentComponent { ...componentProps }
                onSubmit={ this._onSubmit }
                onCancel={ this._oncancel } />
        );
	}
}

We put this inside a modal StackNavigator that always comes on top of every other content in our app and now we can use this anywhere we have access to the navigation prop just like this:

import { StyleSheet, View, Text, TouchableOpacity } = 'react-native';
// ...

class SomeScreenComponent extends React.Component<Props> {
	constructor(props: Props) {
        super(props);
        this.state = {
            userMoodSelection: null,            userAgeSelection: null,            error: null        };
	}
    // ...
    promptUserMoodPressed = () => {        this._selectUserMood()            .then(response => {            	const { success, data } = response;	            if (success) {                	this.setState({ userMoodSelection: data });    	        }	            return true;	        })        	.catch(error => this.setState({ error: error.message }));    }
	promptUserAgePressed = () => {        this._selectUserAge()            .then(response => {            	const { success, data } = response;	            if (success) {                	this.setState({ userAgeSelection: data });    	        }	            return true;	        })        	.catch(error => this.setState({ error: error.message }));    }    
    _selectUserMood(): Promise<{ success: boolean, data?: string }> {        return new Promise(resolve => {        	const params = {		        promptComponent: CustomPromptComponent,        		componentProps: {        			title: 'Select your Mood!',        			options: ['😀', '😐', '😬', '😲', '😡']			    },        		resolve    		}            this.props.navigation.push('PromptsHandlerScreen', params)        })    }    
    _selectUserAge(): Promise<{ success: boolean, data?: string }> {        return new Promise(resolve => {        	const params = {		        promptComponent: CustomPromptComponent,        		componentProps: {        			title: 'Select your Age!',        			options: ['<18', '18+', '30+']			    },        		resolve    		}            this.props.navigation.push('PromptsHandlerScreen', params)        })    }    // ...
    
    render() {
        return (
            <View style={styles.container}>
                <Text>{this.state.userSelection}</Text>
                <TouchableOpacity onPress={this.promptUserMoodPressed}>                    <View style={styles.promptButton}>                        <Text>Select a Mood</Text>                    </View>                </TouchableOpacity>                <TouchableOpacity onPress={this.promptUserAgePressed}>                    <View style={styles.promptButton}>                        <Text>Select an Age</Text>                    </View>                </TouchableOpacity>            </View>
        );
    }
}

Now this is both flexible in terms on options you provide to the user, and more efficient in terms of not triggering unnecessary updates. Flow stays in one place for each prompt, and you have full control of it.

Readibility and reasoning got a lot better but let’s go the extra mile here.

4b. HOC all the things!

We can extract the navigating logic into a method inside a Higher Order Componet and pass that method as a prop to any component that needs the prompt functionality we just implemented:

import { withNavigation } from "react-navigation";
import hoistNonReactStatic from "hoist-non-react-statics";
// ...

type PromptResponse = {
    success: boolean,
    data?: any
}

interface Props {
    navigation: any
}
function withPrompt(OriginalComponent: any) {
    class WithPromptComponent extends React.PureComponent<Props> {
        prompt = (promptComponent: any, componentProps: any): Promise<PromptResponse> => {
            componentProps = componentProps || {};
            return new Promise(resolve => {
                const params = {
                	promptComponent,
                    componentProps,
                    resolve
            	}
                // This is because some times we may have one
        		// prompt after the other and we want the ui to
        		// handle the transition smoothly
                requestAnimationFrame(() => {
                    // we can either use "navigate" to make sure
                    // we always have only one type of prompt currently
                    // open, or "push" to keep stacking prompts
                    // over other prompts.
                    this.props.navigation.navigate("PromptsHandlerScreen", params)
                });
            })
        }
        
        render() {
            return (
                <OriginalComponent {...this.props} prompt={this.prompt} />
            )
        }
    }

	hoistNonReactStatic(WithPromptComponent, OriginalComponent);
	return withNavigation(WithPromptComponent);
}

Now lets have a look at SomeScreenComponent again:

import { StyleSheet, View, Text, TouchableOpacity } = 'react-native';
// ...

type PromptResponse = {    success: boolean,    data?: any}
type Props = {    prompt: (promptComponent: any, componentProps: any) => Promise<PromptResponse>}    
@withPromptclass SomeScreenComponent extends React.Component<Props> {
    // ...
    promptUserMoodPressed = () => {
        const componentProps = {            title: 'Select your Mood!',            options: ['😀', '😐', '😬', '😲', '😡']        }        this.props.prompt(CustomPromptComponent, componentProps)            .then(response => {
            	const { success, data } = response;
	            if (success) {
                	this.setState({ userMoodSelection: data });
    	        }
	            return true;
	        })
        	.catch(error => this.setState({ error: error.message }));
    }

	promptUserAgePressed = () => {
        const componentProps = {            title: 'Select your Age!',            options: ['<18', '18+', '30+']        }        this.props.prompt(CustomPromptComponent, componentProps)            .then(response => {
            	const { success, data } = response;
	            if (success) {
                	this.setState({ userAgeSelection: data });
    	        }
	            return true;
	        })
        	.catch(error => this.setState({ error: error.message }));
    }
    // ...
}

This is even more readable, and we now have the whole control of flow in one method for each prompt. From here on, adding new kinds of prompts like text inputs, multiple selections, multiple text inputs and what not, only needs us to create new components for each type of prompt, make sure they accept a onSubmit and onCancel prop, and that we actually call them with the user input as an argument, and finally navigate to them via the HOC’s prompt method.

This could be done without the need for navigation, maybe with a root component that shows up and then resolves the user input and goes away, but then we would have a bit of a harder time making it work with multiple prompts stacking each on top of the previous one.

What do you thing about this approach? If you liked it, or have any questions, or objections, or generally if you feel like saying anything regarding this approach, please let me know in the comments section below. :)