Design comparison
Solution retrospective
Hello, Frontend Mentor community. This is my solution for the Multi Step Form.
- Feel free to leave any feedback and help me to improve my solution
- I had a lots of fun building this challenge, it was my first time using vite as a complier.
- I started with basic css variables to form a design system, and relied on react's context, provider architecture to handle state.
- Layout was built responsive via mobile first workflow approach.
I just joined community, and was happy to try something a bit more complex. I struggled with some responsive mobile vs desktop organization, and needed to add a javascript hook to handle conditional component rendering. I would appreciate any feedback or if you would do this through another approach!
I also launched this via github pages which was a little difficult to figure out. Currently on every commit push, the build gets compiled to a the gh-pages
branch which then calls github pages standard deploy from branch workflow, which is 2 deployments on every commit. Let me know if you would do this in another way!
Community feedback
- @AhmadYousif89Posted over 1 year ago
Hi Dion 👋
Your solution looks great both in mobile and desktop view, and I just have a couple of notes that I think they would be great to consider implementing in your application :
-
in the 1st step if I click on the "next step" button I get visual errors indicating that I must fill up the form which is great but then I don't get visual feedback if I did enter a valid input value (the error messages remain visible to the user) and it would be a good user experience if you remove those error messages as soon as the user provide a valid value.
-
for the top/side "navigation" elements they doesn't really serve any useful propose other than displaying/highlighting the current step (which is fine) but it would be much better experience if they actually act like navigation btns and give the user the freedom of navigating between steps freely.
-
it would be a better user experience if user can navigate and select/check with only keyboard.
-
consider making the change btn on the last step to actually change the billing cycle and not just navigating the user to step 2 as this is the correct behavior for that btn.
regarding your questions :
-
( "I struggled with some responsive mobile vs desktop organization, and needed to add a Javascript hook to handle conditional component rendering" ) I don't know what you're trying to do here but in general you don't really need a special hook to render components conditionally based on the view port because you can do that easily with Css and media queries, but if you really need a hook that checks for the view ports you can build your own custom hook or use a package from npm called "use-mobile-detect-hook".
-
for deployment I don't really use github for that because there are much better alternatives like vercel or netlify and of course you do auto detect and deploy your code on changes, and I personally like vercel over any other platform.
I hope that was helpful for you
Keep Coding 👊
Marked as helpful1@dionlowPosted over 1 year ago@AhmadYousif89
Thanks for the feedback! Yeah the complexity of the error handling goes up once we want to consider wanting form validation on type only after information has been submitted.
All the points you mentioned are possible enhancements that I can consider!
A use mobile hook was already added to the code because there was an issue with a desktop and a mobile radio button being rendered at the same time. When display is none for mobile or desktop only classes, the radio button is still rendered for both and causes select problems. The solution would be to only render the component once in the same html structure and just change the classes, but wasn't able to get the exact desktop and mobile layouts without using a different html layout.
Can definitely look into vercel and netlify next time!
0@AhmadYousif89Posted over 1 year ago@dionlow
I just took a look at your code and I must say that you have a lot of red flags on your code base
-
first of all you're not writing your code in a "reactive way" especially in the form validation and utility files, you just writing Js (it's not bad or wrong) but that's not how you should write your react code and that why you would find it hard to validate your input the right way.
-
the whole application only has one useState hook (which is managing the steps count 🤷♂️) and that just tells that you're not thinking in a "reactive way"
-
IMO all of the application state should go inside the context you created inside the reducers folder (which doesn't make sense it should be called contexts e.g)
-
why creating two context when they both serve the same general propose, why separating the state from the dispatcher 🤷♂️
-
personally I would refactor the app component and separate the logic in different files that way you will end up with cleaner app component and much cleaner and easier code base to reason about.
-
I don't understand the reason behind the debounce function in "useIsMobile" hook but the clean up function in the useEffect doesn't make sense since the debounce will always return a different function than the "updateSize".
and these are just a few that I had noticed so far
0@dionlowPosted over 1 year ago@AhmadYousif89 Thank you for taking the time to read my code in depth. I have a few questions regarding your feedback.
I've updated the code with the following:
- separated components that have state along with their validation or helper utilities and no state (dummy components)
- renamed
reducer
folder tostate
to allow use of actions, context, and reducers in the future. - moved component sitting in top level App.jsx into other folders (layouts, submit button, step number state etc)
see commit: https://github.com/dionlow/multi-step-form-vite-react/commit/2894909b638cc010bf45172b800effa6192421fa
0@dionlowPosted over 1 year ago@AhmadYousif89
Some questions I have from your feedback.
- would you be able to clarify what it means to write code in a "reactive way"? more explicitly would you be able to give a code example on what is not reactive in my code base and what it would look like if it was?
- Why is useState only being used once a negative thing? Please give an example of how this would be converted into the reactive way.
The useState hook is used only once because the rest of the state is lifted up to the context: our representative of the global store. Step count was not needed at the global store level yet, but I have now moved it to the global state to allow navigation between pages from the side bar as a future feature.
To answer the question of why debounce is added.
- Debounce wraps a function and limits the number of times it can be called within a certain amount of time.
- Instead of calling updateSize continuously upon window update, it will only call once every 100ms or whenever it's detected. We don't expect to see that too often so this is a small performance optimization.
- The clean up function does work as expected. A different function is not returned it's the same wrapped function that is returned which composes some time limit logic. The clean up works as expected.
(note the code is not purely mine, I've also cited the source in the code)
0@AhmadYousif89Posted over 1 year ago@dionlow
Hi again 😂,
-
first of all I can't say your code is wrong or bad and I can't say that the code you have written isn't following the react rules and principles (at the end of the day if it works then 👍), but when I see code like the one in the validateForm.js or the utility.js files I see imperative code rather than declarative one this is more of a "vanilla" js code.
-
for example I can change the Footer component to be something like this (plz keep in mind that this is not how I usually write my logic it's just an example)
const Footer = () => { const [formState, setFormState] = useState({ name: "", email: "", phone: "", plan_id: undefined, }); const errors = { name: formState.name.length === 0 ? "Name is required" : "", email: !formState.email.match(emailRegExp) ? "Invalid email" : "", phone: formState.phone.length === 0 ? "Phone is required" : "", plan_id: formState.plan_id == undefined ? "You must select an option." : "", }; useEffect(() => { // Update errors when formState changes const { hasError } = Object.values(errors).reduce( (result, error) => ({ ...result, hasError: result.hasError || !!error, }), { hasError: false } ); setFormState((prevState) => ({ ...prevState, errors, hasError })); }, [formState]); const onNextStep = () => { // Go to next step if form is valid if (!formState.hasError) { setFormState((prevState) => ({ ...prevState, currentStep: Math.min(formState.currentStep + 1, 5) })); } }; const onBackStep = () => { // Go to previous step setFormState((prevState) => ({ ...prevState, currentStep: Math.max(formState.currentStep - 1, 1) })); }; return ( <footer className="absolute pr-2"> {formState.currentStep <= 4 && ( <SubmitButton stepNo={formState.currentStep} onNextStep={onNextStep} onBackStep={onBackStep} /> )} </footer> ); };
of course I would adjust the code as I need so properly I will move the useState to the context to be globally available and you get the idea.
This is from my personal code for this challenge
- for this particular project I used only this context to handle all the app state and this's just how I think managing global state in react should look like IMO (*of course I could be wrong no one is perfect * 😅)
import { FC, useState, useContext, useCallback, createContext, SetStateAction, PropsWithChildren, } from 'react'; type SubscriptionState = 'active' | 'complete'; export type InputNames = 'name' | 'email' | 'phone' | 'cc'; export type UserInputsValidity = Record<InputNames, boolean>; type UserInputs = Record<InputNames, string>; export type Billing = 'yearly' | 'monthly'; export type PlanTypes = 'Arcade' | 'Advanced' | 'Pro' | ''; type Plan = { type: PlanTypes; price: number }; export type AddonTypes = 'Online service' | 'Large storage' | 'Customizable profile'; type Addon = { type: AddonTypes; price: number }; type InitState = { planInfo: Plan; addons: Addon[]; billing: Billing; stepNumber: number; userInputs: UserInputs; subscriptionState: SubscriptionState; }; const initState: InitState = { addons: [], stepNumber: 1, billing: 'monthly', subscriptionState: 'active', planInfo: { type: '', price: 0 }, userInputs: { name: '', email: '', phone: '', cc: '' }, }; const initContextState: UseSubscriptionContext = { state: initState, setPlanAddon: () => {}, setUserValues: () => {}, setBillingPlan: () => {}, billingSwitcher: () => {}, setSelectedPlan: () => {}, setCurrentStepNumber: () => {}, resetSubscription: () => {}, setSubcriptionState: () => {}, }; const UIContext = createContext<UseSubscriptionContext>(initContextState); export const SubscriptionProvider: FC<PropsWithChildren> = ({ children }) => { return ( <UIContext.Provider value={useSubscriptionContext(initState)}> {children} </UIContext.Provider> ); }; type UseSubscriptionContext = ReturnType<typeof useSubscriptionContext>; const useSubscriptionContext = (initState: InitState) => { const [addons, setAddons] = useState(initState.addons); const [billing, setBilling] = useState(initState.billing); const [planInfo, setPlanInfo] = useState(initState.planInfo); const [stepNumber, setStepNumber] = useState(initState.stepNumber); const [userInputs, setUserInputs] = useState(initState.userInputs); const [subscriptionState, setsubscriptionState] = useState(initState.subscriptionState); /* Set the subscription state to "active" | "complete" */ const setSubcriptionState = useCallback( (subscriptionState: SetStateAction<SubscriptionState>) => setsubscriptionState(subscriptionState), [], ); /* Set the billing cycle state to "monthly" | "yearly" */ const setBillingPlan = useCallback( (billing: SetStateAction<Billing>) => setBilling(billing), [], ); /* Manage the application current step number */ const setCurrentStepNumber = useCallback( (step: SetStateAction<number>) => setStepNumber(step), [], ); /* Manage the user form state in 1st step */ const setUserValues = useCallback( (userInputs: SetStateAction<UserInputs>) => setUserInputs(userInputs), [], ); /* Manage the selected plan in step 2 */ const setSelectedPlan = useCallback( (plan: SetStateAction<Plan>) => setPlanInfo(plan), [], ); /* Manage the plans[] in the state */ const setPlanAddon = useCallback((addon: Addon | SetStateAction<Addon[]>) => { if (typeof addon === 'object' && 'type' in addon && addon !== null) { setAddons(prevAddons => { const exAddon = prevAddons.find(a => a.type === addon.type); if (exAddon) { return prevAddons.filter(a => a.type !== exAddon.type); } return [...prevAddons, { type: addon.type, price: addon.price }]; }); } else setAddons(addon); }, []); /* Switch the billing cycle function */ const billingSwitcher = useCallback(() => { let HIGHEST_IN_MO; let LOWEST_IN_YR; setBillingPlan(billing === 'monthly' ? 'yearly' : 'monthly'); setPlanAddon(pv => { return pv.map(addon => { const { type, price } = addon; HIGHEST_IN_MO = billing === 'monthly' && Math.max(price); LOWEST_IN_YR = billing === 'yearly' && Math.min(price); if (billing === 'monthly') return { type, price: price <= HIGHEST_IN_MO ? price * 10 : price }; if (billing === 'yearly') return { type, price: price >= LOWEST_IN_YR ? price / 10 : price }; return { ...addon }; }); }); setSelectedPlan(pvPlan => { const { type, price } = pvPlan; HIGHEST_IN_MO = billing === 'monthly' && Math.max(price); LOWEST_IN_YR = billing === 'yearly' && Math.min(price); if (billing === 'monthly') return { type, price: price <= HIGHEST_IN_MO ? price * 10 : price }; if (billing === 'yearly') return { type, price: price >= LOWEST_IN_YR ? price / 10 : price }; return { ...pvPlan }; }); }, [billing]); /* Reset the application state */ const resetSubscription = useCallback(() => { setAddons([]); setStepNumber(1); setBilling('monthly'); setsubscriptionState('active'); setPlanInfo({ type: '', price: 0 }); setUserInputs({ name: '', email: '', phone: '', cc: '' }); }, []); const state = { subscriptionState, userInputs, stepNumber, planInfo, billing, addons, }; return { state, setPlanAddon, setUserValues, setBillingPlan, billingSwitcher, setSelectedPlan, setCurrentStepNumber, resetSubscription, setSubcriptionState, }; }; /* Custom hook to extract the global state and set functions to the rest of the application components */ export const useSubscription = () => useContext(UIContext);
with that you manage the application state in one place but ofc you can still add more hooks inside any component as needed (to manage a local state) then you simply use the useSubscription hook anywhere in the app with the latest state snapshot and setter functions.
-
"Why is useState only being used once a negative thing" it's not, I'm sorry if you misunderstood me but I meant to say that in any typical react app we use the useState hook probably more than any other hook in the application and it's the simplest way to manage a state and render jsx elements conditionally inside a react application so that's why I was a bit surprised to see only one useState hook being used.
-
as for the denounce function, yes I get the point but I still think it's not working correctly tho I would just do this instead
const useIsMobile = () => { const [isMobile, setIsMobile] = useState(false); useLayoutEffect(() => { const resizeHandler= debounce(()=> { setIsMobile(window.innerWidth < 768); }, 50 ); resizeHandler(); window.addEventListener('resize', resizeHandler); return () => window.removeEventListener('resize', resizeHandler); }, []); return isMobile; };
or use a ref to hold the reference value in memory but now with this adjustment I'm sure that the clean up function will get the same reference value for the handler.
0@AhmadYousif89Posted over 1 year ago@dionlow
Hey Dion 👋, Yeah the App component looks much better now with the new changes, small changes but makes big difference in readability not just for you but for any other developer how needs to look at your code 👍
The folder containing the state context of your app usually called context or store it's just a naming conventions that's all but of course you can name it however you like 😅
0 -
Please log in to post a comment
Log in with GitHubJoin our Discord community
Join thousands of Frontend Mentor community members taking the challenges, sharing resources, helping each other, and chatting about all things front-end!
Join our Discord