Photo by Lautaro Andreani on Unsplash
Replacing React Hooks with React Redux State Management Library Part 3
Replacing Our State Hooks
Table of contents
In the previous chapter, I discussed the current state of the application, the underlying architecture, how the internal components(specifically the form that handles currency input) functioned and what direction the application needed to go.
This part focuses on how the state that was lifted from the Body
component to the App component functions(a quick run down) before replacing the state hooks with the React Redux library. But first;
What is Redux?
According to the Redux Docs:
Redux is a predictable state container for JavaScript applications that helps us write applications that behave consistently, run in different environments, and are easy to test.
Redux also provides several benefits, which according to the Redux Docs are:
Predictable
Centralized
Debuggable
Flexible
Predictable
Redux helps you write applications that behave consistently, run in different environments (client, server, and native), and are easy to test.
Centralized
Centralizing your application's state and logic enables powerful capabilities like undo/redo, state persistence, and much more.
Debuggable
The Redux DevTools make it easy to trace when, where, why, and how your application's state changed. Redux's architecture lets you log changes, use "time-travel debugging", and even send complete error reports to a server.
Flexible
Redux works with any UI layer and has a large ecosystem of add-ons to fit your needs.
These benefits are points listed in the Redux Docs.
Our App State
Now that we've discussed what Redux is and what we can gain by using the library, we'll go over the logic in our App component:
Starting from the top:
import React, { useState, useEffect, useCallback } from "react"
import LoadingState from "./util/LoadingState/LoadingState"
import Footer from "./components/Footer/Footer"
const Body = React.lazy(() => import("./components/Body/Body"))
const Header = React.lazy(() => import("./components/Header/Header"))
const Nav = React.lazy(() => import("./components/Navigation/Nav"))
We're importing the React library and our state hooks useState
, useEffect
and useCallback
, we're also importing a LoadingState
React component which would serve as our fallback for lazy loading our React components then finally a Footer component. You can read about lazy loading React components on:
The main App component has several state variables created through the useState hook.
function App() {
const [swap, setSwap] = useState(true)
const [defaultNumber, setDefaultNumber] = useState(0)
const [convertedNumber, setConvertedNumber] = useState(null)
const [currencyRate, setCurrencyRate] = useState(null)
const [currency, setCurrency] = useState({
defaultType: 'USD',
defaultTypeAmount: 0,
convertedType: 'NGN',
convertedTypeAmount: 0
})
....
}
The swap
state variable along with its updater function setSwap
are for swapping the place of the currency state object between currency.defaultType
with its currency.defaultTypeAmount
against currency.convertedType
and currency.convertedTypeAmount
state object properties. This just means that the currency object is tracking 4 object keys that represent a currency pair along with a setCurrency
updater function.
We also have the convertedNumber
and currencyRate
which currently are for calculating the newly converted amount of currency along with calculating the rate of the in-memory currency pair.
Below this part of our code:
useEffect(() => {
if (!currencyRate) {
fetch('../data/currencyData.json').then((response) => {
return response.json()
}).then((data) => {
for (const rate of data) {
if (rate["exchange_rate"]) {
setCurrencyRate(Math.floor(rate["exchange_rate"]))
}
}
}).catch((error) => console.error(error))
}
}, [currency.convertedTypeAmount, currencyRate])
useEffect(() => {
if (swap) {
setConvertedNumber(Number(currency.defaultTypeAmount * currencyRate))
}
if (currency.convertedTypeAmount) {
setDefaultNumber(Number(currency.convertedTypeAmount / currencyRate))
}
}, [convertedNumber, currency.defaultAmount, currency.defaultTypeAmount, currency.convertedTypeAmount, currencyRate, swap])
The first useEffect
hook is for mocking an api call to the in-memory data(currencyData.json
) and then setting the currency rate through the setCurrencyRate
updater function, this will give the currency a value on load because we first checked if the !currencyRate
isn't available, since we set the initial currencyRate
to null
this would trigger the fetch function to make the GET request and get our in-memory currency pair data. It's this data that's set by the setCurrency.
The next useEffect
will check if swap
is true and set the convertedNumber
to it's supposed value by multiplying the currencyRate
we get back from the setCurrency
updater function and if swap
is not true we do the conversion of the defaultNumber
by dividing the converted currency by the currency rate.
Next:
const changeCurrencyHandler = (e) => {
const name = e.target.name
const value = e.target.value
const type = e.target.name === "defaultTypeAmount" ? currency.defaultType : currency.convertedType
setCurrency({ ...currency, [name]: Number(value.toLocaleString("en-US", { style: "currency", currency: type})) })
}
const swapCurrencies = useCallback(() => {
const defaultTypes = currency.defaultType
const defaultAmounts = currency.defaultTypeAmount
const convertedTypes = currency.convertedType
const convertedTypeAmounts = currency.convertedTypeAmount
setSwap(!swap)
setCurrency({
...currency,
defaultType: convertedTypes,
defaultTypeAmount: convertedTypeAmounts,
convertedType: defaultTypes,
convertedTypeAmount: defaultAmounts
})
}, [currency, swap])
We have the changeCurrencyHandler
function above along with the swapCurrencies
function that is for swapping the defaultCurrency
value with the convertedCurrency
value. The changeCurrencyHandler
is for handling input, so it is triggered by the onChange
attribute of the currency input element below:
// ConvertedCurrency.jsx
import {
CurrencyInput,
} from '../../styles/BodyUtil'
const ConvertedCurrency = ({currency, changeCurrencyHandler}) => {
return (
<CurrencyInput type="number" name="convertedTypeAmount" id="convertedTypeAmount"
value={currency.convertedTypeAmount || ""}
placeholder="0"
onChange={changeCurrencyHandler}
min={0}
max={999999}
step={0.5}
/>
)
}
export default ConvertedCurrency
// DefaultCurrency.jsx
import {
CurrencyInput
} from '../../styles/BodyUtil'
const DefaultCurrency = ({currency, changeCurrencyHandler}) => {
return (
<CurrencyInput type="number" name="defaultTypeAmount" id="defaultTypeAmount"
value={currency.defaultTypeAmount || ""}
placeholder="0"
onChange={changeCurrencyHandler}
min={0}
max={999999}
step={0.5}
/>
)
}
export default DefaultCurrency
The input elements for the ConvertedCurrency
and DefaultCurrency
components fundamentally do the same thing, accept the currency
and changeCurrencyHandler
function through object destructuring and then use the currency state variable to determine the type of currency and the format the currency should be displayed in, there are also attributes to restrict the range of the currency so the user interface of the input element does not exceed the screen or "overflow".
Finally, we have the user interface that is returned from the App component:
.... // imported hooks and functions i listed earlier
function App() {
... // handlers we've gone through
return (
<React.Suspense fallback={<LoadingState />}>
<div>
<Nav />
<main>
<Header />
<Body
currency={currency}
convertedNumber={convertedNumber}
swapCurrencies={swapCurrencies}
changeCurrencyHandler={changeCurrencyHandler}
defaultNumber={defaultNumber}
/>
<Footer />
</main>
</div>
</React.Suspense>
)
}
export default App
So we are returning a lazy-loaded component that holds the Nav
, Header
and the Body
(which holds the form), this means as we navigate either to or away from the main page there would be an intermission where the fallback LoadingState
component would be shown, you can also see we're passing the state variables currency, convertedNumber
, the handlers swapCurrencies
and changeCurrencyHandler
along with the defaultNumber
variable.
Now that we've gone through the App component, it's state variables and handlers, time to implement Redux and load live data.
Implementing the Redux library
In part 2 of this series we discussed about the Redux library, and how it allowed developers to create a state store and dispatch actions and handle side effects through reducers. Now, we'll be discussing on what we need from Redux in our project. Since we'll be making real API calls, we'll be using Redux asynchronously so we'll be using Redux thunk in that area. Here are the steps we'll be taking:
Installing Redux and setting up a Redux store
Creating our Reducer(s) and action objects
Devise how our REST API should be integrated with our reducers
Extract state and dispatch to our User Interface
Installing Redux and setting up a Redux store
Let's install react-redux, redux-thunk and redux, enter into the terminal:
npm install react-redux --save
npm install redux --save
npm install redux-thunk
You should get a response similar to this after installation:
Great, the packages are installed.
Time to set up the Redux store, we'll go to the main.jsx file:
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import ErrorBoundary from './ErrorBoundary.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ErrorBoundary> // the error boundary we created in part 2 of this series
<App />
</ErrorBoundary>
</React.StrictMode>,
)
This is where we'll import the legacy_createStore method so we can create a global state store, the legacy_createStore method accepts two parameters or arguments, the first is the reducer function and the second is an optional middleware or dev tool(not optional for this project as we would need the thunk middleware and dev tool).
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import ErrorBoundary from './ErrorBoundary.jsx'
import './index.css'
import {legacy_createStore, applyMiddleware, compose} from "redux"
import thunk from "redux-thunk"
import {Provider} from "react-redux"
import currencyReducer from './util/ReduxReducers/currencyReducer/currencyReducer.js'
const composeEnhancer = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
const store = legacy_createStore(currencyReducer, composeEnhancer(applyMiddleware(thunk)))
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<Provider store={store}>
<ErrorBoundary>
<App />
</ErrorBoundary>
</Provider>
</React.StrictMode>,
)
With a bit of help from Stackoverflow we've set up the Redux devtools. This gives us access to the time-travel debugging feature Redux has.
Creating our Reducer(s) and action objects
We've created a reducer function that is exported from a currencyReducer.js
file which is stored in a ReduxReducers folder inside of our util folder(breaking up the main state from the rest of our application).
// currencyReducer.js
const READY = "READY"
const FETCH = "FETCH"
const RESET = "RESET"
const CHANGE = "CHANGE"
const initialState = {
old_currency : "USD",
old_amount: 0,
new_currency: "NGN",
new_amount: 0
}
const currencyReducer = (state = initialState, action) => {
console.log(state)
switch(action.type) {
case READY:
return state;
case FETCH:
return state;
case RESET:
return state;
case CHANGE:
return {...state, ...action};
default:
return state;
}
}
export default currencyReducer;
NOTE: This is just a rough guestimate as to what the reducer would be like. It is modeled after the data the API provides, which follows the same structure as the initialState
.
Devise how our REST API should be integrated with our reducers
Our Currency Converter API is gotten from the "Convert Currency" API from api-ninjas. The returned JSON data is modelled below:
{
"new_amount": 3838849.02,
"new_currency": "NGN",
"old_currency": "USD",
"old_amount": 5000
}
As you can see the JSON data above matches the scheme of our initialState object. We are also provided a table of over a 100 currencies.
Currency Symbol | Currency Name | Currency Symbol | Currency Name |
AED | UAE Dirham | AFN | Afghan Afghani |
ALL | Albanian Lek | AMD | Armenian Dram |
ANG | Netherlands Antillean Gulden | AOA | Angolan Kwanza |
ARS | Argentine Peso | AUD | Australian Dollar |
AWG | Aruban Florin | AZN | Azerbaijani Manat |
BAM | Bosnia And Herzegovina Konvertibilna Marka | BBD | Barbadian Dollar |
BDT | Bangladeshi Taka | BGN | Bulgarian Lev |
BHD | Bahraini Dinar | BIF | Burundi Franc |
BMD | Bermudan Dollar | BND | Brunei Dollar |
BOB | Bolivian Boliviano | BRL | Brazilian Real |
BSD | Bahamian Dollar | BTC | Bitcoin |
BTN | Bhutanese Ngultrum | BWP | Botswana Pula |
... | .... | ... | ... |
Wow, that's quite some data!
We are going to map over the page and return all currency symbols to our developer console on the site:
// IN OUR BROWSERS DEVELOPER CONSOLE ON API-NINJAS CURRENCY CONVERTER PAGE
/* select the table header rows, they're a Node List */
let rows = document.querySelectorAll("th[scope='row']")
/* loop through the Node List to return only the symbol and names and
save symbols to array */
let currencySymbol = []
for (const data of rows) {
currencySymbol.push(data.innerText)
}
let symbols = currencySymbol.join("_") // creates a string of currency symbols separated by a _.
This gives us all the currency symbols in the API, copy the data in the symbols variable. Create a currencySymbol.js
file in a currencySymbol folder under the util folder and store the data.
// currencySymbol.js
let symbols = 'AED_AFN_ALL_AMD_ANG_AOA_ARS_AUD_AWG_AZN_BAM_BBD_BDT_BGN_BHD_BIF_BMD_BND_BOB_BRL_BSD_BTC_BTN_BWP_BYN_BYR_BZD_CAD_CDF_CHF_CLF_CLP_CNY_COP_CRC_CUC_CUP_CVE_CZK_DJF_DKK_DOP_DZD_EGP_ERN_ETB_EUR_FJD_FKP_GBP_GEL_GGP_GHS_GIP_GMD_GNF_GTQ_GYD_HKD_HNL_HRK_HTG_HUF_IDR_ILS_IMP_INR_IQD_IRR_ISK_JEP_JMD_JOD_JPY_KES_KGS_KHR_KMF_KPW_KRW_KWD_KYD_KZT_LAK_LBP_LKR_LRD_LSL_LVL_LYD_MAD_MDL_MGA_MKD_MMK_MNT_MOP_MRO_MUR_MVR_MWK_MXN_MYR_MZN_NAD_NGN_NIO_NOK_NPR_NZD_OMR_PAB_PEN_PGK_PHP_PKR_PLN_PYG_QAR_RON_RSD_RUB_RWF_SAR_SBD_SCR_SDG_SEK_SGD_SHP_SLL_SOS_SRD_STD_SVC_SYP_SZL_THB_TJS_TMT_TND_TOP_TRY_TTD_TWD_TZS_UAH_UGX_USD_UYU_UZS_VEF_VND_VUV_WST_XAF_XAG_XCD_XDR_XOF_XPF_YER_ZAR_ZMK_ZMW_ZWL'
let currencySymbols = symbols.split("_"); // this will split the symbols string into an array of symbols
export default currencySymbols;
We can also extract the names of the currencies.
let namesOfCurrencies = 'UAE Dirham_Afghan Afghani_Albanian Lek_Armenian Dram_Netherlands Antillean Gulden_Angolan Kwanza_Argentine Peso_Australian Dollar_Aruban Florin_Azerbaijani Manat_Bosnia And Herzegovina Konvertibilna Marka_Barbadian Dollar_Bangladeshi Taka_Bulgarian Lev_Bahraini Dinar_Burundi Franc_Bermudan Dollar_Brunei Dollar_Bolivian Boliviano_Brazilian Real_Bahamian Dollar_Bitcoin_Bhutanese Ngultrum_Botswana Pula_New Belarusian Ruble_Belarusian Ruble_Belize Dollar_Canadian Dollar_Congolese Franc_Swiss Franc_Chilean Unit Of Account_Chilean Peso_Chinese Yuan_Colombian Peso_Costa Rican Colon_Cuban Convertible Peso_Cuban Peso_Cape Verdean Escudo_Czech Koruna_Djiboutian Franc_Danish Krone_Dominican Peso_Algerian Dinar_Egyptian Pound_Eritrean Nakfa_Ethiopian Birr_Euro_Fijian Dollar_Falkland Islands Pound_British Pound_Georgian Lari_Guernsey Pound_Ghanaian Cedi_Gibraltar Pound_Gambian Dalasi_Guinean Franc_Guatemalan Quetzal_Guyanese Dollar_Hong Kong Dollar_Honduran Lempira_Croatian Kuna_Haitian Gourde_Hungarian Forint_Indonesian Rupiah_Israeli New Sheqel_Manx pound_Indian Rupee_Iraqi Dinar_Iranian Rial_Icelandic Króna_Jersey Pound_Jamaican Dollar_Jordanian Dinar_Japanese Yen_Kenyan Shilling_Kyrgyzstani Som_Cambodian Riel_Comorian Franc_North Korean Won_South Korean Won_Kuwaiti Dinar_Cayman Islands Dollar_Kazakhstani Tenge_Lao Kip_Lebanese Lira_Sri Lankan Rupee_Liberian Dollar_Lesotho Loti_Latvian Lats_Libyan Dinar_Moroccan Dirham_Moldovan Leu_Malagasy Ariary_Macedonian Denar_Myanma Kyat_Mongolian Tugrik_Macanese Pataca_Mauritanian Ouguiya_Mauritian Rupee_Maldivian Rufiyaa_Malawian Kwacha_Mexican Peso_Malaysian Ringgit_Mozambican Metical_Namibian Dollar_Nigerian Naira_Nicaraguan Cordoba_Norwegian Krone_Nepalese Rupee_New Zealand Dollar_Omani Rial_Panamanian Balboa_Peruvian Nuevo Sol_Papua New Guinean Kina_Philippine Peso_Pakistani Rupee_Polish Zloty_Paraguayan Guarani_Qatari Riyal_Romanian Leu_Serbian Dinar_Russian Ruble_Rwandan Franc_Saudi Riyal_Solomon Islands Dollar_Seychellois Rupee_Sudanese Pound_Swedish Krona_Singapore Dollar_Saint Helena Pound_Sierra Leonean Leone_Somali Shilling_Surinamese Dollar_Sao Tome And Principe Dobra_Salvadoran Colón_Syrian Pound_Swazi Lilangeni_Thai Baht_Tajikistani Somoni_Turkmenistan Manat_Tunisian Dinar_Paanga_Turkish New Lira_Trinidad and Tobago Dollar_New Taiwan Dollar_Tanzanian Shilling_Ukrainian Hryvnia_Ugandan Shilling_United States Dollar_Uruguayan Peso_Uzbekistani Som_Venezuelan Bolivar_Vietnamese Dong_Vanuatu Vatu_Samoan Tala_Central African CFA Franc_Silver (troy ounce)_East Caribbean Dollar_Special Drawing Rights_West African CFA Franc_CFP Franc_Yemeni Rial'
let currencyNames = namesOfCurrencies.split("_") // splits the currencyNames into an array
export default currencyNames
Wonderful, we have the names of all our currencies including the symbols, lets test it out in our option component in our form:
// ... After importing all necessary files
import currencySymbols from '../../util/CurrencySymbols/currencySymbols'
import currencyNames from '../../util/CurrencyNames/currencyNames'
function Body({currency, swapCurrencies, changeCurrencyHandler, convertedNumber, defaultNumber}) {
const oldCurrency = useSelector((state) => state.old_currency)
const oldAmount = useSelector((state) => state.old_amount)
const newCurrency = useSelector((state) => state.new_currency)
const newAmount = useSelector((state) => state.new_amount)
const dispatch = useDispatch()
function selectCurrency(e) {
const value = e.target.value
const name = e.target.name
dispatch({ type: "CHANGE", [name]: value})
}
return (
<FormElement>
<Legend>Swap and Compare Currency</Legend>
<FirstFieldSet>
<DefaultLabel
defaultNumber={defaultNumber}
currency={currency} />
<DefaultCurrency
currency={currency}
changeCurrencyHandler={changeCurrencyHandler} />
</FirstFieldSet>
<SecondFieldSet>
<span style={{display: "flex", width: "max-content", margin: "auto", gap: "15px"}}>
<Select value={oldCurrency} onChange={selectCurrency} name="old_currency">
{currencySymbols.map((symbol, idx) => (
<Option key={symbol} title={currencyNames[idx]} value={symbol}>{symbol}</Option>
))}
</Select>
<Select value={newCurrency} onChange={selectCurrency} name="new_currency">
{currencySymbols.map((symbol, idx) => (
<Option key={symbol} title={currencyNames[idx]} value={symbol}>{symbol}</Option>
))}
</Select>
</span>
<SwapButton
currency={currency}
swapCurrencies={swapCurrencies} />
<DefaultResetButton />
</SecondFieldSet>
<ThirdFieldSet>
<ConvertedAmountLabel
convertedNumber={convertedNumber}
currency={currency} />
<ConvertedCurrency
currency={currency}
changeCurrencyHandler={changeCurrencyHandler} />
</ThirdFieldSet>
</FormElement>
)
}
export default Body
Great! We're doing a couple of things here, we're importing the currencySymbols
array to our select
element and then we map each currency in it into an option
element while also giving each option
element a title attribute and then passing the currencyNames
array to it and selecting the index of each currency being viewed with idx
.
Extract State and Dispatch to our User Interface
Now that Redux is set up, we clear the application of all previous states that aren't relevant to Redux.
Then begin integrating our state into the components, starting from the Body
component. In the Body
component we send the oldCurrency
and newCurrency
to the SwapButton
component.
// previos styles,
import currencySymbols from
'../../util/CurrencySymbols/currencySymbols'
import { useSelector, useDispatch } from "react-redux"
function Body() {
const oldCurrency = useSelector((state) => state.old_currency)
const oldAmount = useSelector((state) => state.old_amount)
const newCurrency = useSelector((state) => state.new_currency)
const newAmount = useSelector((state) => state.new_amount)
const dispatch = useDispatch()
function selectCurrency(e) {
const value = e.target.value
const name = e.target.name
dispatch({ type: "CHANGE", [name]: value})
}
return (
<FormElement>
<Legend>Swap and Compare Currency</Legend>
<FirstFieldSet>
<DefaultLabel />
<DefaultCurrency />
</FirstFieldSet>
<SecondFieldSet>
<Span>
<Select value={oldCurrency} onChange={selectCurrency} name="old_currency">
{currencySymbols.map((symbol) => (
<Option key={symbol} value={symbol}>{symbol}</Option>
))}
</Select>
<Select value={newCurrency} onChange={selectCurrency} name="new_currency">
{currencySymbols.map((symbol) => (
<Option key={symbol} value={symbol}>{symbol}</Option>
))}
</Select>
</Span>
<SwapButton
oldCurrency={oldCurrency}
newCurrency={newCurrency}
<DefaultResetButton />
</SecondFieldSet>
<ThirdFieldSet>
<ConvertedAmountLabel />
<ConvertedCurrency />
</ThirdFieldSet>
</FormElement>
)
}
export default Body
We then destructure and use the state to display the button.
We also send the oldAmount
("USD default value") to the DefaultCurrency
component, we import useDispatch
and then create a handleInput
function to make the input a controlled input that updates the state through dispatch.
import {
CurrencyInput
} from '../../styles/BodyUtil'
import { useDispatch } from "react-redux"
const DefaultCurrency = ({oldAmount}) => {
const dispatch = useDispatch()
const handleInput = (e) => {
const name = e.target.name
const value = Number(e.target.value) || 0
dispatch({type: "CHANGE", [name]: value})
}
return (
<CurrencyInput type="number" name="old_amount" id="defaultTypeAmount"
value={oldAmount || ""}
placeholder="0"
onChange={handleInput}
min={0}
max={999999}
step={0.5}
/>
)
}
export default DefaultCurrency
We do the same for the convertedCurrency
but using the newCurrency
.
import {
CurrencyInput,
} from '../../styles/BodyUtil'
import { useDispatch } from "react-redux"
const ConvertedCurrency = ({newAmount}) => {
const dispatch = useDispatch()
const handleInput = (e) => {
const name = e.target.name
const value = Number(e.target.value) || 0
dispatch({type: "CHANGE", [name]: value})
}
return (
<CurrencyInput type="number" name="new_amount" id="convertedTypeAmount"
value={newAmount || ""}
placeholder="0"
onChange={handleInput}
min={0}
max={999999}
step={0.5}
/>
)
}
export default ConvertedCurrency
Now that we've controlled the inputs in our input elements we can work on the logic to fetch our currency from the api.
We create a fetchCurrency.js
file in a fetchCurrency folder under our utils folder.
// fetchCurrency.js
export const fetchCurrency = (value, oldCurrency, newCurrency) => async dispatch => {
if (value === 0) return
console.log(oldCurrency, newCurrency, value)
const data = await fetch(`https://api.api-ninjas.com/v1/convertcurrency?want=${oldCurrency}&have=${newCurrency}&amount=${value}`, {
method: 'get',
headers: {
'X-Api-Key': `${import.meta.env.VITE_API_KEY}`
},
})
const response = await data.json()
console.log(response)
dispatch({type: "FETCH", ...response})
}
In the file we create a fetchCurrency
function that accepts 3 parameters, value
, oldCurrency
and newCurrency
, we make use of redux thunk for wait for our fetch request asynchronously while for each currency input we use the lodash debounce library to debounce requests so we don't make requests too fast.
/* eslint-disable react/prop-types */
/* ... styles and others */
import debounce from 'lodash.debounce'
const DefaultCurrency = ({oldAmount, oldCurrency, newCurrency}) => {
const dispatch = useDispatch()
const callForData = debounce((value) => {
dispatch(fetchCurrency(value, oldCurrency, newCurrency))
}, 700)
const handleInput = (e) => {
const name = e.target.name
const value = Number(e.target.value) || 0
dispatch({type: "CHANGE", [name]: value})
callForData(value)
}
return (
<CurrencyInput type="number" name="old_amount" id="defaultTypeAmount"
value={oldAmount || ""}
placeholder="0"
onChange={handleInput}
min={0}
max={999999}
step={0.5}
/>
)
}
export default DefaultCurrency
But there's a problem here.
If both the default currency input and converted currency input have their values set to our Redux state variables of old_amount and new_amount then everytime we make API requests we'd only be updating the converted currency amount inside of the converted currency input.
This is a terrible User Experience. Instead, the best approach would probably be to add a boolean in our code that swaps which input element serves as the default. Normally, this would've been the solution, but since we're using Redux as a state store, we can just create a swap action that swaps our states., then dispatch this action anytime we need it.
const FETCH = "FETCH"
const RESET = "RESET"
const CHANGE = "CHANGE"
const SWAP = "SWAP"
const initialState = {
old_currency : "USD",
old_amount: 0,
new_currency: "NGN",
new_amount: 0,
}
const currencyReducer = (state = initialState, action) => {
switch(action.type) {
case FETCH:
return {
...state,
...action,
};
case RESET:
return {
...initialState
};
case CHANGE:
return {
...state,
...action,
};
case SWAP:
return {
...state,
old_currency: state.new_currency,
old_amount: state.new_amount,
new_currency: state.old_currency,
new_amount: state.old_amount,
}
default:
return state;
}
}
export default currencyReducer
Great! Now we can swap the currencies. But things are getting crowded in the currencyReducer so we'll create a payloadReducer to handle the incoming data from the API.
// payloadReducer.js
const LOADING = "LOADING"
const FETCH_CONVERTED = "FETCH_CONVERTED"
const FETCH_DEFAULT = "FETCH_DEFAULT"
const RESET = "RESET"
const CONVERT = "CONVERT"
const RESOLVED = "RESOLVED"
const UNCONVERT = "UNCONVERT"
const initialState = {
loading: null,
payload_amount: 0,
payload_currency: "",
converted: false,
}
const payloadReducer = (state = initialState, action) => {
switch(action.type) {
case LOADING: // whenever input is given, we dispatch this action
return {
...state,
loading: true,
}
case CONVERT: //whenever the converted currency input is used we dispatch this action
return {
...state, converted: true,
}
case UNCONVERT: //whenever the default currecny input is used we dispatch this action
return {
...state,
converted: false,
}
case RESOLVED: // whenever we get a successful response from api, change loading to false
return {
...state,
loading: false,
}
case FETCH_CONVERTED: // fetch currencies given to convert currency field
return {
...state,
converted: true,
payload_amount: action.amount,
payload_currency: action.currency,
}
case FETCH_DEFAULT: // fetch currencies given to default field
return {
...state,
converted: false,
payload_amount: action.amount,
payload_currency: action.currency,
}
case RESET: // reset the both currency states to the default initial state
return {
...initialState
}
default:
return state
}
}
export default payloadReducer
After creating these cases in the payloadReducer that will handle our fetching of currency data from input we set the fetchCurrency function to handle these cases.
export const fetchCurrency = (value, oldCurrency, newCurrency, converted) => async dispatch => {
const data = await fetch(`https://api.api-ninjas.com/v1/convertcurrency?want=${newCurrency}&have=${oldCurrency}&amount=${value}`, {
method: 'get',
headers: {
'X-Api-Key': `${import.meta.env.VITE_API_KEY}` // importing my api key
},
})
const response = await data.json()
console.log(response)
if (response) {
dispatch({type: "RESOLVED"}) // changes the loading state to false
switch(converted) {
case true:
return dispatch({
type: "FETCH_CONVERTED", // gets the new currency when converted currency input is active
amount: response.new_amount,
currency: response.new_currency
})
case false:
return dispatch({
type: "FETCH_DEFAULT", // gets the new currency when the default currency field is active
amount: response.new_amount,
currency: response.new_currency
})
}
}
We extract the converted
property from our state and use it to keep track of the payload to send back.
For our defaultCurrency
we pass the oldAmount
, oldCurrency
and newCurrency
states into the component, then we extract the converted
state to send this information to our fetchCurrency
function while the states we destructure into the component is used to dynamically show what value to display using the converted
property as a boolean to render the value we want.
Lastly, we use debounce
function from lodash-debounce
package. and defer our api calls by 270ms
.
// imported useSelector, useDispatch and others
const DefaultCurrency = ({oldAmount, oldCurrency, newCurrency}) => {
const converted = useSelector((state) => state.payloadReducer.converted)
const dispatch = useDispatch()
const callForData = debounce((value) => {
dispatch(fetchCurrency(value, oldCurrency, newCurrency, converted))
}, 270) // we wait 270ms before sending the request
const handleInput = (e) => {
const name = e.target.name
const value = Number(e.target.value)
dispatch({type: "LOADING"}) // loading state starts
dispatch({type: "UNCONVERT"}) // convert property set to false
dispatch({type: "CHANGE", [name]: value}) // controls input
return callForData(value)
}
return (
<CurrencyInput type="number" name="old_amount" id="defaultTypeAmount"
value={converted ? "" : oldAmount || ""} // if convert is true, make the value empty, else replace with old value, if old value is null leave it empty
placeholder="0"
onChange={handleInput}
min={0}
max={999999}
step={0.5}
/>
)
}
export default DefaultCurrency
Similar is done with the ConvertedCurrency component.
// imported useSelector, useDispatch and others
const ConvertedCurrency = ({newAmount, newCurrency, oldCurrency}) => {
const converted = useSelector((state) => state.payloadReducer.converted)
const dispatch = useDispatch()
const callForData = debounce((value) => {
let olderCurrency = newCurrency
let newerCurrency = oldCurrency
dispatch({type: "LOADING"})
dispatch(fetchCurrency(value, olderCurrency, newerCurrency, converted))
}, 270)
const handleInput = (e) => {
const name = e.target.name
const value = Number(e.target.value)
dispatch({type: "CONVERT"})
dispatch({type: "CHANGE", [name]: value})
return callForData(value)
}
return (
<CurrencyInput type="number" name="new_amount" id="convertedTypeAmount"
value={!converted ? "" : newAmount || ""}
placeholder="0"
onChange={handleInput}
min={0}
max={999999}
step={0.5}
/>
)
}
export default ConvertedCurrency
The last thing that we do is create the logic for the labels as they would be conditionally displaying the value we want based on what input was activated.
The DefaultLabel
component accepts 2 parameters, the first is the oldAmount
property prom our currencyReducer
, the second is the oldCurrency
reducer, we are also extracting our payloadReducer
's state.
const DefaultLabel = ({oldAmount = 0, oldCurrency}) => {
const converted = useSelector((state) => state.payloadReducer.converted)
const payloadAmount = useSelector((state) => state.payloadReducer.payload_amount)
const payloadCurrency = useSelector((state) => state.payloadReducer.payload_currency)
const loading = useSelector((state) => state.payloadReducer.loading)
return (
<Label htmlFor="defaultTypeAmount">
<ConvertTitle>Converted <br/>{!converted ? "from" : "to"}</ConvertTitle>
{
!converted && !loading ?
(
oldAmount.toLocaleString("en-US", {
style: "currency",
currency: `${oldCurrency}`,
minimumFractionalDigits: 2
}))
:
!converted && loading ? (
oldAmount.toLocaleString("en-US", {
style: "currency",
currency: `${oldCurrency}`,
minimumFractionalDigits: 2
})
) :
(
payloadAmount.toLocaleString("en-US", {
style: "currency",
currency: `${payloadCurrency || oldCurrency}`,
minimumFractionalDigits: 2
})
)
}
</Label>
)
}
export default DefaultLabel
We use the converted
property to determine what the label should display, if the value and loading are false it displays the oldAmount
, else if the value of the converted
property is false and loading is true we still show the oldAmount
, else we show the payloadAmount
.
We do a similar thing for the ConvertedLabel component.
Wonderful, we're done implementing the main functionality of our Currency Converter application, more will still be added, like a cryptocurrency swap function and routing but for now the logic for the app is complete.