App CRUD em React Native usando Redux e Hooks

Este projeto está disponível em https://github.com/totemarcal/CRUDReact

1- Crie um projeto chamada CRUDApp

create-react-native-app CRUDApp

2- Abra o projeto com o Visual Code e crie uma pasta chamada app

3- Dentro da pasta app crie o arquivo sample.json com o seguinte conteúdo:

{
    "quotes": [
        {
            "id":1,
            "author": "Nelson Mandela",
            "text": "The greatest glory in living lies not in never falling, but in rising every time we fall."
        },
        {
            "id":2,
            "author": "Walt Disney",
            "text": "The way to get started is to quit talking and begin doing."
        },
        {
            "id":3,
            "author": "Steve Jobs",
            "text": "Your time is limited, so don't waste it living someone else's life. Don't be trapped by dogma – which is living with the results of other people's thinking."
        },
        {
            "id":4,
            "author": "Eleanor Roosevelt",
            "text": "If life were predictable it would cease to be life, and be without flavor."
        }
    ]
}

Redux

Redux seria um controlador de estados geral para sua aplicação. Compartilhar estados entre vários componentes diferentes se torna uma coisa muito fácil quando o utilizamos. O Redux é basicamente divido em 3 partes: storereducers e actions.

store

“store” é o um conjunto de estados da sua aplicação, um objeto JavaScript que possui todos os estados dos seus componentes.

reducers

Cada dado da store deve ter o seu próprio reducer, por exemplo: o dado “user” teria o seu reducer, chamado “userReducer”. Um reducer é encarregado de lidar com todas as ações, como algum componente pedindo para alterar algum dado da store.

actions

Actions são responsáveis por requisitar algo para um reducer. Elas devem ser sempre funções puras, o que, dizendo de uma forma leiga, quer dizer que elas devem APENAS enviar os dados ao reducer, nada além disso.

4- Instalando o Redux

npm install react-redux
yarn add react-redux
yarn add redux
yarn add redux-thunk

5- Na pasta app crie a pasta store/quotes. Dentro da pasta store crie um arquivo index.js com o seguinte conteúdo:

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';

//import reducers from '../reducers.js'; //Import the reducer
import  reducers from './reducers';

// Connect our store to the reducers
export default createStore(reducers, applyMiddleware(thunk));

6- Na pasta app/store/quotes crie o arquivo actions.js com o seguinte conteúdo:

ADD_QUOTE
Esta ação é a operação CREATE, a nova cotação é passada para a função addQuote.
QUOTES_AVAILABLE
Esta ação atuará como a operação READ, as cotações serão passadas para a função addQuotes.
UPDATE_QUOTE
Esta ação é a operação UPDATE, a cotação atualizada é passada para a função updateQuote.
DELETE_QUOTE
Esta ação é a operação DELETE, o ID da cotação excluída é passado para a função deleteQuote.

export const QUOTES_AVAILABLE = 'QUOTES_AVAILABLE';
export const ADD_QUOTE = 'ADD_QUOTE';
export const UPDATE_QUOTE = 'UPDATE_QUOTE';
export const DELETE_QUOTE = 'DELETE_QUOTE';

// Get Quotes
export const addQuotes = (quotes) => ({
    type: QUOTES_AVAILABLE,
    data: {quotes}
});

// Add Quote - CREATE (C)
export const addQuote = (quote) => ({
    type: ADD_QUOTE,
    data: {quote}
});

// Update Quote - UPDATE (U)
export const updateQuote = (quote) => ({
    type: UPDATE_QUOTE,
    data: {quote}
});

// Delete Quote - DELETE (D)
export const deleteQuote = (id) => ({
    type: DELETE_QUOTE,
    data: {id}
});

7- Na pasta app/store/quotes crie o arquivo reducer.js com o seguinte conteúdo:

ADD_QUOTE
A variável de estado ‘quote’ é clonada e a nova cotação é enviada para a para o objeto clonado, a variável de estado ‘quote’ é substituída pelo objeto clone.
QUOTES_AVAILABLE
A variável de estado ‘quote’ é atualizada com a matriz de quotes enviada ao reducer.
UPDATE_QUOTE
A variável ‘quote’ do estado é clonada, o ID da cotação enviada para o reducer é usado para encontrar o índice da cotação no objeto clonado.
A cotação nesse índice é substituída pela cotação enviada ao redutor. A variável de estado ‘quote’ é substituída pelo objeto clone.
DELETE_QUOTE
A variável de estado ‘quote’ é clonada, o ID enviado ao reducer é usado para encontrar o índice da cotação no objeto clonado.
A cotação nesse índice é removida e a variável ‘quote’ do estado é substituída pelo objeto clone.

import { QUOTES_AVAILABLE, ADD_QUOTE, UPDATE_QUOTE, DELETE_QUOTE } from "./actions" //Import the actions types constant we defined in our actions

let dataState = { quotes: [] };

export default function dataReducer (state = dataState, action) {
    switch (action.type) {
        case ADD_QUOTE:
            let { quote } = action.data;

            //clone the current state
            let clone = JSON.parse(JSON.stringify(state.quotes));

            clone.unshift(quote); //add the new quote to the top

            return {...state, quotes: clone};

        case QUOTES_AVAILABLE:
            let { quotes } = action.data;

            return {...state, quotes};

        case UPDATE_QUOTE:{
            let { quote } = action.data;

            //clone the current state
            let clone = JSON.parse(JSON.stringify(state.quotes));

            //check if bookmark already exist
            const index = clone.findIndex((obj) => obj.id === quote.id);

            //if the quote is in the array, update the quote
            if (index !== -1) clone[index] = quote;

            return {...state, quotes: clone};
        }

        case DELETE_QUOTE:{
            let { id } = action.data;

            //clone the current state
            let clone = JSON.parse(JSON.stringify(state.quotes));

            //check if quote already exist
            const index = clone.findIndex((obj) => obj.id === id);

            //if the quote is in the array, remove the quote
            if (index !== -1) clone.splice(index, 1);

            return {...state, quotes: clone};
        }

        default:
            return state;
    }
};

// Combine all the reducers
//const rootReducer = combineReducers({dataReducer});

//export default rootReducer;

8- Na pasta app/store crie o arquivo reducers.js com o seguinte conteúdo:

import quotes from './quotes/reducer';
//import quotes2 from './quotes/reducer';
import { combineReducers } from 'redux';


const rootReducer = combineReducers({quotes}); //, quotes2});

export default rootReducer;


React Hooks

A funcionalidade de Hooks trazida a partir da versão 16.7.0 do React visa basicamente oferecer formas de trabalharmos com estado e outras API’s sem a necessidade de ter uma classe (stateful component).

useState

O hook mais comum utilizado para controlarmos alguma variável de estado dentro de um functional component no React. Para utilizar definimos por exemplo:

const [count, setCount] = useState(0);

Então, para manipularmos o valor de count podemos simplesmente executar:

<button onClick={() => setCount(count + 1)}>+</button>

useRef

Por padrão, os efeitos são executados após cada renderização concluída, mas você pode optar por dispará-los somente quando determinados valores receberam atualização. No entanto, nem todos os efeitos podem ser adiados. Para estes tipos de efeitos, React fornece um Hook adicional chamado useLayoutEffect. Tem a mesma estrutura que useEffect, mas é disparado em momentos diferentes.

Veja mais sobre React Hooks em https://blog.rocketseat.com.br/react-hooks/

9- Cria uma pasta chamada components dentro da pasta app.

10- Crie o arquivo ListItem.js dentro da pasta components e adicione o seguinte código:

import React, {useRef} from 'react';
import { View, Text, StyleSheet, Animated } from 'react-native';

import {RectButton} from 'react-native-gesture-handler';
import Swipeable from 'react-native-gesture-handler/Swipeable';

let colours = ["#ff8e42", "#4F6384"];

export default function ListItem ({item, index, navigation, onDelete, onEdit}){
    const inputEl = useRef(null);

    const RightActions = ({ progress, dragX, onPress, item}) => {
        const scale = dragX.interpolate({
            inputRange: [-100, 0],
            outputRange: [1, 0],
            extrapolate: 'clamp',
        });
        return (
            <View style={styles.buttons}>
                <RectButton onPress={() =>  {
                    inputEl.current.close();
                    onEdit(item);
                }}>
                    <View style={[styles.rightAction, styles.editAction]}>
                        <Animated.Text style={[styles.actionText, { transform: [{ scale }] }]}>
                            Edit
                        </Animated.Text>
                    </View>
                </RectButton>
                <RectButton onPress={() => {
                    inputEl.current.close();
                    onDelete(item.id)
                }}>
                    <View style={[styles.rightAction, styles.deleteAction]}>
                        <Animated.Text style={[styles.actionText, { transform: [{ scale }] }]}>
                            Delete
                        </Animated.Text>
                    </View>
                </RectButton>
            </View>
        );
    };

    //Returns a colour based on the index
    function random() {
        if (index % 2 === 0) { //check if its an even number
            return colours[0];
        }else{
            return colours[1];
        }
    }

    return (
        <Swipeable  ref={inputEl}
            renderRightActions={(progress, dragX) => (
                <RightActions progress={progress} dragX={dragX} item={item}/>
            )}>
            <View style={styles.row}>
                <View style={[styles.container, {backgroundColor: random()}]}>
                    <Text style={styles.quote}>
                        {item.text}
                    </Text>
                    <Text style={styles.author}>
                        {item.author}
                    </Text>
                </View>
            </View>
        </Swipeable>
    )

};



const styles = StyleSheet.create({
    row:{
        borderBottomWidth: StyleSheet.hairlineWidth,
        borderBottomColor:"#ccc",
        backgroundColor: '#FFF',
        padding: 10
    },

    container:{
        padding: 10
    },

    author: {
        marginTop: 25,
        marginBottom: 10,
        
        fontSize: 15,
        color: '#FFF',
        textAlign: "right"
    },

    quote: {
        marginTop: 5,
        
        fontSize: 17,
        lineHeight: 21,
        color: '#FFF',
    },

    buttons:{
        width: 190,
        flexDirection: 'row'
    },

    rightAction: {
        alignItems: 'center',
        justifyContent: 'center',
        flex: 1,
        width: 95,
    },

    editAction: {
        backgroundColor: '#497AFC'
    },

    deleteAction: {
        backgroundColor: '#dd2c00'
    },

    actionText: {
        color: '#fff',
        fontWeight: '600',
        padding: 20,
    }
});

11- Crie o arquivo LoadingScreen.js dentro da pasta components e adicione o seguinte código:

import React, {useEffect} from 'react';
import {AsyncStorage} from 'react-native';
import { AppLoading } from 'expo';

import SampleData from '../sample'

//1 - LOADING SCREEN
export default function LoadingScreen(props) {

    useEffect(() => checkLocalData(), []);

    function checkLocalData(){
        //Check if LocalStorage has been populated with the sample data
        AsyncStorage.getItem('quotes', (err, data) => {
            //if it doesn't exist, extract from json fil
            if (data === null){
                AsyncStorage.setItem('quotes', JSON.stringify(SampleData.quotes));//save the initial data in Async

                props.navigation.navigate('App'); //Navigate to the home page
            }else{
                props.navigation.navigate('App'); //Navigate to the home page
            }
        });
    }

    return <AppLoading/>;
}

12- Crie o arquivo Home.js dentro da pasta components e adicione o seguinte código:

import React, {useEffect, useState} from 'react';
import {
    FlatList,
    StyleSheet,
    SafeAreaView,
    View,
    Text,
    ActivityIndicator,
    TouchableHighlight,
    AsyncStorage
} from 'react-native';
import { useDispatch, useSelector } from 'react-redux';

import axios from 'axios';

import { addQuotes, deleteQuote } from "../store/quotes/actions";

import ListItem from "./ListItem";

export default function Home(props) {
    const dispatch = useDispatch();
    const { navigation } = props;

    //1 - DECLARE VARIABLES
    const [isFetching, setIsFetching] = useState(false);

    //Access Redux Store State
    const { quotes } = useSelector((state) => state.quotes);
    //const { quotes } = dataReducer;

    //==================================================================================================

    //2 - MAIN CODE BEGINS HERE
    useEffect(() => getData(), []);

    //==================================================================================================

    //3 - GET FLATLIST DATA
    const getData = () => {
        setIsFetching(true);

        //OPTION 1 - LOCAL DATA
        AsyncStorage.getItem('quotes', (err, quotes) => {
            if (err) alert(err.message);
            else if (quotes !== null) dispatch(addQuotes(JSON.parse(quotes)));

            setIsFetching(false)
        });

        //OPTION 2 - FAKE API
        // let url = "https://my-json-server.typicode.com/mesandigital/demo/quotes";
        // axios.get(url)
        //     .then(res => res.data)
        //     .then((data) => dispatch(addQuotes(data)))
        //     .catch(error => alert(error.message))
        //     .finally(() => setIsFetching(false));
    };

    //==================================================================================================

    //4 - RENDER FLATLIST ITEM
    const renderItem = ({item, index}) => {
        return (
            <ListItem item={item} index={index} navigation={navigation} onDelete={onDelete} onEdit={onEdit}/>
        )
    };

    //==================================================================================================

    //5 - EDIT QUOTE
    const onEdit = (item) => {
        navigation.navigate('NewQuote', {quote: item, title:"Edit Quote"})
    };

    //==================================================================================================

    //6 - DELETE QUOTE
    const onDelete = (id) => {

        //OPTION 1 - UPDATE LOCAL STORAGE DATA
        AsyncStorage.getItem('quotes', (err, quotes) => {
            if (err) alert(err.message);
            else if (quotes !== null){
                quotes = JSON.parse(quotes);

                //find the index of the quote with the id passed
                const index = quotes.findIndex((obj) => obj.id === id);

                // remove the quote
                if (index !== -1) quotes.splice(index, 1);

                //Update the local storage
                AsyncStorage.setItem('quotes', JSON.stringify(quotes), () => dispatch(deleteQuote(id)));
            }
        });

        //OPTION 2 - FAKE API
        // let url = "https://my-json-server.typicode.com/mesandigital/demo/quotes";
        // axios.delete(url, {data:{id:id}})
        //     .then((res) => dispatch(deleteQuote(id)))
        //     .catch(error => alert(error.message))
        //     .finally(() => setIsFetching(false));
    };

    //==================================================================================================

    //7 - RENDER
    if (isFetching) {
        return (
            <View style={styles.activityIndicatorContainer}>
                <ActivityIndicator animating={true}/>
            </View>
        );
    } else{
        return (
            <SafeAreaView style={styles.container}>
                <FlatList
                    data={quotes}
                    renderItem={renderItem}
                    keyExtractor={(item, index) => `quotes_${index}`}/>

                <TouchableHighlight style={styles.floatingButton}
                                    underlayColor='#ff7043'
                                    onPress={() => navigation.navigate('NewQuote', {title:"New Quote"})}>
                    <Text style={{fontSize: 25, color: 'white'}}>+</Text>
                </TouchableHighlight>
            </SafeAreaView>
        );
    }
};

const styles = StyleSheet.create({

    container: {
        flex:1,
        backgroundColor: '#F5F5F5'
    },

    activityIndicatorContainer:{
        backgroundColor: "#fff",
        alignItems: 'center',
        justifyContent: 'center',
        flex: 1,
    },

    floatingButton:{
        backgroundColor: '#6B9EFA',
        borderColor: '#6B9EFA',
        height: 55,
        width: 55,
        borderRadius: 55 / 2,
        alignItems: 'center',
        justifyContent: 'center',
        position: 'absolute',
        bottom: 60,
        right: 15,
        shadowColor: "#000000",
        shadowOpacity: 0.5,
        shadowRadius: 2,
        shadowOffset: {
            height: 1,
            width: 0
        }
    }
});

13- Crie o arquivo NewQuote.js dentro da pasta components e adicione o seguinte código:

import React, {useState} from 'react';
import {
    KeyboardAvoidingView,
    SafeAreaView,
    StyleSheet,
    Text,
    TextInput,
    TouchableHighlight,
    View,
    AsyncStorage
} from 'react-native';

import {useDispatch} from 'react-redux';
import {Header} from 'react-navigation-stack';

import axios from "axios";

import {addQuote, updateQuote} from "../store/quotes/actions";


const MAX_LENGTH = 250;

export default function NewQuote(props) {
    const dispatch = useDispatch();
    const {navigation} = props;

    let quote = navigation.getParam('quote', null);

    //1 - DECLARE VARIABLES
    const [isSaving, setIsSaving] = useState(false);
    const [author, setAuthor] = useState(quote ? quote.author : "");
    const [text, setText] = useState(quote ? quote.text : "");

    //==================================================================================================

    //2 - GET FLATLIST DATA
    const onSave = () => {
        let edit = quote !== null;
        let quote_ = {};

        if (edit) {
            quote_ = quote;
            quote_['author'] = author;
            quote_['text'] = text;
        } else {
            let id = generateID();
            quote_ = {"id": id, "author": author, "text": text};
        }

        //OPTION 1 - ADD TO LOCAL STORAGE DATA
        AsyncStorage.getItem('quotes', (err, quotes) => {
            if (err) alert(err.message);
            else if (quotes !== null){
                quotes = JSON.parse(quotes);

                if (!edit){
                    //add the new quote to the top
                    quotes.unshift(quote_);
                }else{
                    //find the index of the quote with the quote id
                    const index = quotes.findIndex((obj) => obj.id === quote_.id);

                    //if the quote is in the array, replace the quote
                    if (index !== -1) quotes[index] = quote_;
                }

                //Update the local storage
                AsyncStorage.setItem('quotes', JSON.stringify(quotes), () => {
                    if (!edit) dispatch(addQuote(quote_));
                    else dispatch(updateQuote(quote_));

                    navigation.goBack();
                });
            }
        });

        //OPTION 2 - FAKE API
        // let url = "https://my-json-server.typicode.com/mesandigital/demo/quotes";
        // axios.post(url, quote_)
        //     .then(res => res.data)
        //     .then((data) => {
        //         dispatch(quote ? updateQuote(data) : addQuote(data));
        //         navigation.goBack();
        //     })
        //     .catch(error => alert(error.message))
    };

    //==================================================================================================

    //3 - GENERATE ID
    const generateID = () => {
        let d = new Date().getTime();
        let id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
            let r = (d + Math.random() * 16) % 16 | 0;
            d = Math.floor(d / 16);
            return (c == 'x' ? r : (r &amp; 0x3 | 0x8)).toString(5);
        });

        return id;
    };

    //==================================================================================================

    //4 - RENDER
    let disabled = (author.length > 0 &amp;&amp; text.length > 0) ? false : true;
    return (
        <KeyboardAvoidingView keyboardVerticalOffset={Header.HEIGHT} style={styles.flex} behavior="padding">
            <SafeAreaView style={styles.flex}>
                <View style={styles.flex}>
                    <TextInput
                        onChangeText={(text) => setAuthor(text)}
                        placeholder={"Author"}
                        autoFocus={true}
                        style={[styles.author]}
                        value={author}/>
                    <TextInput
                        multiline={true}
                        onChangeText={(text) => setText(text)}
                        placeholder={"Enter Quote"}
                        style={[styles.text]}
                        maxLength={MAX_LENGTH}
                        value={text}/>
                </View>

                <View style={styles.buttonContainer}>
                    <View style={{flex: 1, justifyContent: "center"}}>
                        <Text style={[styles.count, (MAX_LENGTH - text.length <= 10) &amp;&amp; {color: "red"}]}> {MAX_LENGTH - text.length}</Text>
                    </View>
                    <View style={{flex: 1, alignItems: "flex-end"}}>
                        <TouchableHighlight style={[styles.button]} disabled={disabled} onPress={onSave}
                                            underlayColor="rgba(0, 0, 0, 0)">
                            <Text style={[styles.buttonText, {color: disabled ? "rgba(255,255,255,.5)" : "#FFF"}]}>
                                Save
                            </Text>
                        </TouchableHighlight>
                    </View>
                </View>
            </SafeAreaView>
        </KeyboardAvoidingView>
    );
}


const styles = StyleSheet.create({
    flex: {
        flex: 1
    },

    buttonContainer: {
        height: 70,
        flexDirection: "row",
        padding: 12,
        backgroundColor: "white"
    },

    count: {
        
        fontSize: 17,
        color: "#6B9EFA"
    },

    button: {
        width: 80,
        height: 44,
        borderRadius: 8,
        justifyContent: "center",
        alignItems: 'center',
        backgroundColor: "#6B9EFA"
    },

    buttonText: {
        
        fontSize: 16,
    },

    author: {
        fontSize: 20,
        lineHeight: 22,
        
        height: 80,
        padding: 16,
        backgroundColor: 'white',
    },

    text: {
        fontSize: 30,
        lineHeight: 33,
        
        color: "#333333",
        padding: 16,
        paddingTop: 16,
        minHeight: 170,
        borderTopWidth: 1,
        borderColor: "rgba(212,211,211, 0.3)"
    }
});

14- Crie o arquivo Index.js dentro da pasta components e adicione o seguinte código:

import { createAppContainer, createSwitchNavigator } from 'react-navigation';
import { createStackNavigator } from 'react-navigation-stack';

import LoadingScreen from './LoadingScreen'
import HomeScreen from './Home'
import NewQuoteScreen from './NewQuote'

const AppStack = createStackNavigator({
    Home:{
        screen: HomeScreen,
        navigationOptions: ({ navigation }) => ({
            title: `Home`,
        }),
    },
    NewQuote:{
        screen: NewQuoteScreen,
        navigationOptions: ({ navigation }) => ({
            title: `New Quote`,
        }),
    }
});

const RoutesStack = createSwitchNavigator(
    {
        Loading: LoadingScreen,
        App: AppStack
    },
    {initialRouteName: 'Loading'}
);

const Router = createAppContainer(RoutesStack);

export default Router;

15- Crie o arquivo App.js na raiz do projeto e adicione o seguinte código:

import React, { Component } from 'react';
import { Provider } from 'react-redux';

import store from './app/store'; //Import the store
import Router from './app/components/index' //Import the component file

export default class App extends Component {
    render() {
        return (
            <Provider store={store}>
                <Router/>
            </Provider>
        );
    }
}